diff --git a/CHANGELOG.md b/CHANGELOG.md index 55a03a310..e2636c28d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,7 @@ - Slides: add structured element geometry, styled text runs, table-cell content, image source URLs, native presentation metadata, and read-only text location with exact UTF-16 ranges. (#822) — thanks @sebsnyk. - Slides: add range-scoped styling, links, bullets, and object-scoped replacement; `replace-text` now requires explicit `--object`, `--page`, or `--all` scope instead of silently changing the whole deck. (#823, #835) — thanks @sebsnyk. - Slides: add native table creation and zero-based table-cell targeting for `insert-text`, including atomic cell replacement. (#824, #834) — thanks @sebsnyk. +- Slides: add native themed slide creation, duplication, and reordering with predefined or exact custom layouts and explicit zero-based positions. (#826, #833) — thanks @sebsnyk. ### Fixed diff --git a/README.md b/README.md index 44fca7b51..e5ddb4b02 100644 --- a/README.md +++ b/README.md @@ -384,6 +384,7 @@ Docs: [Slides from Markdown](docs/slides-markdown.md), [introspection](docs/slides-introspection.md), [text editing](docs/slides-text-editing.md), [tables](docs/slides-tables.md), +[slide structure](docs/slides-structure.md), [`gog slides`](docs/commands/gog-slides.md), [`gog forms`](docs/commands/gog-forms.md). @@ -396,6 +397,9 @@ gog slides style-text --range 0:12 --bold --size 24 gog slides replace-text old new --object gog slides table create --rows 2 --cols 3 gog slides insert-text "Revenue" --row 0 --col 0 --replace +gog slides new-slide --layout TITLE_AND_BODY --index 1 +gog slides duplicate-slide --to-index 2 +gog slides move-slide --to-index 0 gog slides insert-image chart.png --x 24 --y 24 --width 240 gog slides insert-text "New text" gog forms update --quiz=true diff --git a/docs/commands.generated.md b/docs/commands.generated.md index 48ec9eee4..1dadb9b02 100644 --- a/docs/commands.generated.md +++ b/docs/commands.generated.md @@ -592,6 +592,7 @@ Generated from `gog schema --json`. - [`gog slides (slide) create-from-markdown [flags]`](commands/gog-slides-create-from-markdown.md) - Create a Google Slides presentation from markdown - [`gog slides (slide) create-from-template <templateId> <title> [flags]`](commands/gog-slides-create-from-template.md) - Create a presentation from template with text replacements - [`gog slides (slide) delete-slide <presentationId> <slideId>`](commands/gog-slides-delete-slide.md) - Delete a slide by object ID + - [`gog slides (slide) duplicate-slide <presentationId> <slideId> [flags]`](commands/gog-slides-duplicate-slide.md) - Duplicate a slide by object ID - [`gog slides (slide) export (download,dl) <presentationId> [flags]`](commands/gog-slides-export.md) - Export a Google Slides deck (pdf|pptx) - [`gog slides (slide) info (get,show) <presentationId>`](commands/gog-slides-info.md) - Get Google Slides presentation metadata - [`gog slides (slide) insert-image --width=FLOAT-64 <presentationId> <slideId> [<image>] [flags]`](commands/gog-slides-insert-image.md) - Insert a local or public image at a position and size @@ -599,6 +600,8 @@ Generated from `gog schema --json`. - [`gog slides (slide) link --range=STRING <presentationId> <objectId> [flags]`](commands/gog-slides-link.md) - Apply a hyperlink to a text range in one page element - [`gog slides (slide) list-slides <presentationId>`](commands/gog-slides-list-slides.md) - List all slides with their object IDs - [`gog slides (slide) locate (find-element) <presentationId> <text> [flags]`](commands/gog-slides-locate.md) - Locate text in shapes and table cells with object IDs and UTF-16 ranges + - [`gog slides (slide) move-slide --to-index=TO-INDEX <presentationId> <slideId>`](commands/gog-slides-move-slide.md) - Move a slide to a zero-based insertion index + - [`gog slides (slide) new-slide <presentationId> [flags]`](commands/gog-slides-new-slide.md) - Create a native themed slide - [`gog slides (slide) raw <presentationId> [flags]`](commands/gog-slides-raw.md) - Dump raw Google Slides API response as JSON (Presentations.Get; lossless; for scripting and LLM consumption) - [`gog slides (slide) read-slide <presentationId> <slideId> [flags]`](commands/gog-slides-read-slide.md) - Read slide content: speaker notes, text elements, and images - [`gog slides (slide) replace-slide <presentationId> <slideId> [<image>] [flags]`](commands/gog-slides-replace-slide.md) - Replace an existing slide image from a local file or public URL diff --git a/docs/commands/README.md b/docs/commands/README.md index 438fe9c41..9618cb62a 100644 --- a/docs/commands/README.md +++ b/docs/commands/README.md @@ -2,7 +2,7 @@ Every `gog` command has a generated docs page. The source of truth is the live CLI schema; run `make docs-commands` after changing command names, flags, help text, aliases, or arguments. -Generated pages: 652. +Generated pages: 655. ## Top-level Commands @@ -643,6 +643,7 @@ Generated pages: 652. - [gog slides create-from-markdown](gog-slides-create-from-markdown.md) - Create a Google Slides presentation from markdown - [gog slides create-from-template](gog-slides-create-from-template.md) - Create a presentation from template with text replacements - [gog slides delete-slide](gog-slides-delete-slide.md) - Delete a slide by object ID + - [gog slides duplicate-slide](gog-slides-duplicate-slide.md) - Duplicate a slide by object ID - [gog slides export](gog-slides-export.md) - Export a Google Slides deck (pdf|pptx) - [gog slides info](gog-slides-info.md) - Get Google Slides presentation metadata - [gog slides insert-image](gog-slides-insert-image.md) - Insert a local or public image at a position and size @@ -650,6 +651,8 @@ Generated pages: 652. - [gog slides link](gog-slides-link.md) - Apply a hyperlink to a text range in one page element - [gog slides list-slides](gog-slides-list-slides.md) - List all slides with their object IDs - [gog slides locate](gog-slides-locate.md) - Locate text in shapes and table cells with object IDs and UTF-16 ranges + - [gog slides move-slide](gog-slides-move-slide.md) - Move a slide to a zero-based insertion index + - [gog slides new-slide](gog-slides-new-slide.md) - Create a native themed slide - [gog slides raw](gog-slides-raw.md) - Dump raw Google Slides API response as JSON (Presentations.Get; lossless; for scripting and LLM consumption) - [gog slides read-slide](gog-slides-read-slide.md) - Read slide content: speaker notes, text elements, and images - [gog slides replace-slide](gog-slides-replace-slide.md) - Replace an existing slide image from a local file or public URL diff --git a/docs/commands/gog-slides-duplicate-slide.md b/docs/commands/gog-slides-duplicate-slide.md new file mode 100644 index 000000000..53b682060 --- /dev/null +++ b/docs/commands/gog-slides-duplicate-slide.md @@ -0,0 +1,46 @@ +# `gog slides duplicate-slide` + +> Generated from `gog schema --json`. Do not edit this page by hand; run `make docs-commands`. + +Duplicate a slide by object ID + +## Usage + +```bash +gog slides (slide) duplicate-slide <presentationId> <slideId> [flags] +``` + +## Parent + +- [gog slides](gog-slides.md) + +## Flags + +| Flag | Type | Default | Help | +| --- | --- | --- | --- | +| `--access-token` | `string` | | Use provided access token directly (bypasses stored refresh tokens; token expires in ~1h) | +| `-a`<br>`--account`<br>`--acct` | `string` | | Account email, alias, or auto for authenticated Google API commands | +| `--client` | `string` | | OAuth client name (selects stored credentials + token bucket) | +| `--color` | `string` | auto | Color output: auto\|always\|never | +| `--disable-commands` | `string` | | Comma-separated list of disabled commands; dot paths allowed | +| `-n`<br>`--dry-run`<br>`--dryrun`<br>`--noop`<br>`--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) | +| `--enable-commands-exact` | `string` | | Comma-separated list of exact enabled commands; dot paths allowed and parent commands do not enable children | +| `-y`<br>`--force`<br>`--assume-yes`<br>`--yes` | `bool` | | Skip confirmations for destructive commands | +| `--gmail-no-send` | `bool` | false | Block Gmail send operations (agent safety) | +| `-h`<br>`--help` | `kong.helpFlag` | | Show context-sensitive help. | +| `--home` | `string` | | Override gogcli config/data/state/cache root (equivalent to GOG_HOME) | +| `-j`<br>`--json`<br>`--machine` | `bool` | false | Output JSON to stdout (best for scripting) | +| `--no-input`<br>`--non-interactive`<br>`--noninteractive` | `bool` | | Never prompt; fail instead (useful for CI) | +| `-p`<br>`--plain`<br>`--tsv` | `bool` | false | Output stable, parseable text to stdout (TSV; no colors) | +| `--results-only` | `bool` | | In JSON mode, emit only the primary result (drops envelope fields like nextPageToken) | +| `--select`<br>`--pick`<br>`--project` | `string` | | In JSON mode, select comma-separated fields (best-effort; supports dot paths). Desire path: use --fields for most commands. | +| `--to-index` | `*int64` | | Zero-based insertion index for the duplicated slide | +| `-v`<br>`--verbose` | `bool` | | Enable verbose logging | +| `--version` | `kong.VersionFlag` | | Print version and exit | +| `--wrap-untrusted` | `bool` | false | In JSON/raw output, wrap fetched text fields in external untrusted-content markers | + +## See Also + +- [gog slides](gog-slides.md) +- [Command index](README.md) diff --git a/docs/commands/gog-slides-move-slide.md b/docs/commands/gog-slides-move-slide.md new file mode 100644 index 000000000..d6cb0ef9e --- /dev/null +++ b/docs/commands/gog-slides-move-slide.md @@ -0,0 +1,46 @@ +# `gog slides move-slide` + +> Generated from `gog schema --json`. Do not edit this page by hand; run `make docs-commands`. + +Move a slide to a zero-based insertion index + +## Usage + +```bash +gog slides (slide) move-slide --to-index=TO-INDEX <presentationId> <slideId> +``` + +## Parent + +- [gog slides](gog-slides.md) + +## Flags + +| Flag | Type | Default | Help | +| --- | --- | --- | --- | +| `--access-token` | `string` | | Use provided access token directly (bypasses stored refresh tokens; token expires in ~1h) | +| `-a`<br>`--account`<br>`--acct` | `string` | | Account email, alias, or auto for authenticated Google API commands | +| `--client` | `string` | | OAuth client name (selects stored credentials + token bucket) | +| `--color` | `string` | auto | Color output: auto\|always\|never | +| `--disable-commands` | `string` | | Comma-separated list of disabled commands; dot paths allowed | +| `-n`<br>`--dry-run`<br>`--dryrun`<br>`--noop`<br>`--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) | +| `--enable-commands-exact` | `string` | | Comma-separated list of exact enabled commands; dot paths allowed and parent commands do not enable children | +| `-y`<br>`--force`<br>`--assume-yes`<br>`--yes` | `bool` | | Skip confirmations for destructive commands | +| `--gmail-no-send` | `bool` | false | Block Gmail send operations (agent safety) | +| `-h`<br>`--help` | `kong.helpFlag` | | Show context-sensitive help. | +| `--home` | `string` | | Override gogcli config/data/state/cache root (equivalent to GOG_HOME) | +| `-j`<br>`--json`<br>`--machine` | `bool` | false | Output JSON to stdout (best for scripting) | +| `--no-input`<br>`--non-interactive`<br>`--noninteractive` | `bool` | | Never prompt; fail instead (useful for CI) | +| `-p`<br>`--plain`<br>`--tsv` | `bool` | false | Output stable, parseable text to stdout (TSV; no colors) | +| `--results-only` | `bool` | | In JSON mode, emit only the primary result (drops envelope fields like nextPageToken) | +| `--select`<br>`--pick`<br>`--project` | `string` | | In JSON mode, select comma-separated fields (best-effort; supports dot paths). Desire path: use --fields for most commands. | +| `--to-index` | `*int64` | | Zero-based insertion index where the slide should be moved | +| `-v`<br>`--verbose` | `bool` | | Enable verbose logging | +| `--version` | `kong.VersionFlag` | | Print version and exit | +| `--wrap-untrusted` | `bool` | false | In JSON/raw output, wrap fetched text fields in external untrusted-content markers | + +## See Also + +- [gog slides](gog-slides.md) +- [Command index](README.md) diff --git a/docs/commands/gog-slides-new-slide.md b/docs/commands/gog-slides-new-slide.md new file mode 100644 index 000000000..445a0df93 --- /dev/null +++ b/docs/commands/gog-slides-new-slide.md @@ -0,0 +1,48 @@ +# `gog slides new-slide` + +> Generated from `gog schema --json`. Do not edit this page by hand; run `make docs-commands`. + +Create a native themed slide + +## Usage + +```bash +gog slides (slide) new-slide <presentationId> [flags] +``` + +## Parent + +- [gog slides](gog-slides.md) + +## Flags + +| Flag | Type | Default | Help | +| --- | --- | --- | --- | +| `--access-token` | `string` | | Use provided access token directly (bypasses stored refresh tokens; token expires in ~1h) | +| `-a`<br>`--account`<br>`--acct` | `string` | | Account email, alias, or auto for authenticated Google API commands | +| `--client` | `string` | | OAuth client name (selects stored credentials + token bucket) | +| `--color` | `string` | auto | Color output: auto\|always\|never | +| `--disable-commands` | `string` | | Comma-separated list of disabled commands; dot paths allowed | +| `-n`<br>`--dry-run`<br>`--dryrun`<br>`--noop`<br>`--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) | +| `--enable-commands-exact` | `string` | | Comma-separated list of exact enabled commands; dot paths allowed and parent commands do not enable children | +| `-y`<br>`--force`<br>`--assume-yes`<br>`--yes` | `bool` | | Skip confirmations for destructive commands | +| `--gmail-no-send` | `bool` | false | Block Gmail send operations (agent safety) | +| `-h`<br>`--help` | `kong.helpFlag` | | Show context-sensitive help. | +| `--home` | `string` | | Override gogcli config/data/state/cache root (equivalent to GOG_HOME) | +| `--index` | `*int64` | | Zero-based insertion index for the new slide | +| `-j`<br>`--json`<br>`--machine` | `bool` | false | Output JSON to stdout (best for scripting) | +| `--layout` | `*string` | | Predefined slide layout; defaults to BLANK | +| `--layout-id` | `string` | | Exact presentation layout object ID from 'slides info --json'; mutually exclusive with --layout | +| `--no-input`<br>`--non-interactive`<br>`--noninteractive` | `bool` | | Never prompt; fail instead (useful for CI) | +| `-p`<br>`--plain`<br>`--tsv` | `bool` | false | Output stable, parseable text to stdout (TSV; no colors) | +| `--results-only` | `bool` | | In JSON mode, emit only the primary result (drops envelope fields like nextPageToken) | +| `--select`<br>`--pick`<br>`--project` | `string` | | In JSON mode, select comma-separated fields (best-effort; supports dot paths). Desire path: use --fields for most commands. | +| `-v`<br>`--verbose` | `bool` | | Enable verbose logging | +| `--version` | `kong.VersionFlag` | | Print version and exit | +| `--wrap-untrusted` | `bool` | false | In JSON/raw output, wrap fetched text fields in external untrusted-content markers | + +## See Also + +- [gog slides](gog-slides.md) +- [Command index](README.md) diff --git a/docs/commands/gog-slides.md b/docs/commands/gog-slides.md index 3e21f1b78..1532b3155 100644 --- a/docs/commands/gog-slides.md +++ b/docs/commands/gog-slides.md @@ -23,6 +23,7 @@ gog slides (slide) <command> [flags] - [gog slides create-from-markdown](gog-slides-create-from-markdown.md) - Create a Google Slides presentation from markdown - [gog slides create-from-template](gog-slides-create-from-template.md) - Create a presentation from template with text replacements - [gog slides delete-slide](gog-slides-delete-slide.md) - Delete a slide by object ID +- [gog slides duplicate-slide](gog-slides-duplicate-slide.md) - Duplicate a slide by object ID - [gog slides export](gog-slides-export.md) - Export a Google Slides deck (pdf|pptx) - [gog slides info](gog-slides-info.md) - Get Google Slides presentation metadata - [gog slides insert-image](gog-slides-insert-image.md) - Insert a local or public image at a position and size @@ -30,6 +31,8 @@ gog slides (slide) <command> [flags] - [gog slides link](gog-slides-link.md) - Apply a hyperlink to a text range in one page element - [gog slides list-slides](gog-slides-list-slides.md) - List all slides with their object IDs - [gog slides locate](gog-slides-locate.md) - Locate text in shapes and table cells with object IDs and UTF-16 ranges +- [gog slides move-slide](gog-slides-move-slide.md) - Move a slide to a zero-based insertion index +- [gog slides new-slide](gog-slides-new-slide.md) - Create a native themed slide - [gog slides raw](gog-slides-raw.md) - Dump raw Google Slides API response as JSON (Presentations.Get; lossless; for scripting and LLM consumption) - [gog slides read-slide](gog-slides-read-slide.md) - Read slide content: speaker notes, text elements, and images - [gog slides replace-slide](gog-slides-replace-slide.md) - Replace an existing slide image from a local file or public URL diff --git a/docs/slides-structure.md b/docs/slides-structure.md new file mode 100644 index 000000000..96d208587 --- /dev/null +++ b/docs/slides-structure.md @@ -0,0 +1,43 @@ +# Slides structure + +Create native, editable slides without rendering an image first: + +```bash +gog slides new-slide <presentationId> +gog slides new-slide <presentationId> --layout TITLE_AND_BODY --index 1 +``` + +Without a layout flag, Google creates a blank slide. `--layout` accepts the +Slides predefined layouts. A presentation theme can remove or modify a +predefined layout; Google returns an error when the selected layout is not +available in the active master. + +For an exact theme layout, read its object ID from `slides info --json` and use +`--layout-id`: + +```bash +gog slides info <presentationId> --json +gog slides new-slide <presentationId> --layout-id <layoutId> +``` + +`--layout` and `--layout-id` are mutually exclusive. `--index` is zero-based; +omitting it appends the slide. + +## Duplicate and move + +Find stable slide IDs, then duplicate or reorder them: + +```bash +gog slides list-slides <presentationId> --json +gog slides duplicate-slide <presentationId> <slideId> +gog slides duplicate-slide <presentationId> <slideId> --to-index 2 +gog slides move-slide <presentationId> <slideId> --to-index 0 +``` + +Without `--to-index`, Google places a duplicate immediately after its source. +With `--to-index`, duplication and positioning happen in one batch update. +Move indexes are zero-based and refer to the presentation order before the move, +matching the Slides API. An index can range from zero through the slide count. + +Use `--dry-run --json` to inspect the exact batch request without contacting +Google, and `slides list-slides --json` to verify the resulting order. diff --git a/internal/cmd/slides.go b/internal/cmd/slides.go index d57a0bb57..1ac3546a7 100644 --- a/internal/cmd/slides.go +++ b/internal/cmd/slides.go @@ -22,6 +22,9 @@ type SlidesCmd struct { CreateFromTemplate SlidesCreateFromTemplateCmd `cmd:"" name:"create-from-template" help:"Create a presentation from template with text replacements"` Copy SlidesCopyCmd `cmd:"" name:"copy" aliases:"cp,duplicate" help:"Copy a Google Slides presentation"` AddSlide SlidesAddSlideCmd `cmd:"" name:"add-slide" help:"Add a slide with a full-bleed image and optional speaker notes"` + NewSlide SlidesNewSlideCmd `cmd:"" name:"new-slide" help:"Create a native themed slide"` + DuplicateSlide SlidesDuplicateSlideCmd `cmd:"" name:"duplicate-slide" help:"Duplicate a slide by object ID"` + MoveSlide SlidesMoveSlideCmd `cmd:"" name:"move-slide" help:"Move a slide to a zero-based insertion index"` ListSlides SlidesListSlidesCmd `cmd:"" name:"list-slides" help:"List all slides with their object IDs"` DeleteSlide SlidesDeleteSlideCmd `cmd:"" name:"delete-slide" help:"Delete a slide by object ID"` ReadSlide SlidesReadSlideCmd `cmd:"" name:"read-slide" help:"Read slide content: speaker notes, text elements, and images"` diff --git a/internal/cmd/slides_structural.go b/internal/cmd/slides_structural.go new file mode 100644 index 000000000..48cb0319d --- /dev/null +++ b/internal/cmd/slides_structural.go @@ -0,0 +1,292 @@ +package cmd + +import ( + "context" + "fmt" + "strings" + "time" + + "google.golang.org/api/slides/v1" + + "github.com/steipete/gogcli/internal/outfmt" + "github.com/steipete/gogcli/internal/ui" +) + +type SlidesNewSlideCmd struct { + PresentationID string `arg:"" name:"presentationId" help:"Presentation ID"` + Layout *string `name:"layout" enum:"BLANK,CAPTION_ONLY,TITLE,TITLE_AND_BODY,TITLE_AND_TWO_COLUMNS,TITLE_ONLY,SECTION_HEADER,SECTION_TITLE_AND_DESCRIPTION,ONE_COLUMN_TEXT,MAIN_POINT,BIG_NUMBER" help:"Predefined slide layout; defaults to BLANK"` + LayoutID string `name:"layout-id" help:"Exact presentation layout object ID from 'slides info --json'; mutually exclusive with --layout"` + Index *int64 `name:"index" help:"Zero-based insertion index for the new slide"` +} + +func (c *SlidesNewSlideCmd) Run(ctx context.Context, flags *RootFlags) error { + u := ui.FromContext(ctx) + + presentationID := strings.TrimSpace(c.PresentationID) + if presentationID == "" { + return usage("empty presentationId") + } + layout := "" + if c.Layout != nil { + layout = strings.TrimSpace(*c.Layout) + } + layoutID := strings.TrimSpace(c.LayoutID) + if layout != "" && layoutID != "" { + return usage("--layout and --layout-id are mutually exclusive") + } + if c.Index != nil && *c.Index < 0 { + return usage("--index must be >= 0") + } + + slideID := newSlidesStructuralObjectID("gogSlide") + createSlide := &slides.CreateSlideRequest{ + ObjectId: slideID, + SlideLayoutReference: slidesStructuralLayoutReference(layout, layoutID), + } + if c.Index != nil { + createSlide.InsertionIndex = *c.Index + createSlide.ForceSendFields = []string{"InsertionIndex"} + } + + body := &slides.BatchUpdatePresentationRequest{ + Requests: []*slides.Request{ + {CreateSlide: createSlide}, + }, + } + payload := map[string]any{ + "presentation_id": presentationID, + "slide_object_id": slideID, + "batch_update": body, + } + if layout != "" { + payload["layout"] = layout + } + if layoutID != "" { + payload["layout_id"] = layoutID + } + if c.Index != nil { + payload["index"] = *c.Index + } + if err := dryRunExit(ctx, flags, "slides.new-slide", payload); err != nil { + return err + } + + account, err := requireAccount(flags) + if err != nil { + return err + } + slidesSvc, err := slidesService(ctx, account) + if err != nil { + return err + } + if _, err := slidesSvc.Presentations.BatchUpdate(presentationID, body).Context(ctx).Do(); err != nil { + return fmt.Errorf("create slide: %w", err) + } + + if outfmt.IsJSON(ctx) { + out := map[string]any{ + "presentationId": presentationID, + "slideObjectId": slideID, + } + if layoutID != "" { + out["layoutId"] = layoutID + } else { + out["layout"] = slidesStructuralLayoutName(layout) + } + if c.Index != nil { + out["index"] = *c.Index + } + return outfmt.WriteJSON(ctx, stdoutWriter(ctx), out) + } + + u.Out().Linef("slideObjectId\t%s", slideID) + u.Out().Linef("presentationId\t%s", presentationID) + if layoutID != "" { + u.Out().Linef("layoutId\t%s", layoutID) + } else { + u.Out().Linef("layout\t%s", slidesStructuralLayoutName(layout)) + } + if c.Index != nil { + u.Out().Linef("index\t%d", *c.Index) + } + return nil +} + +type SlidesDuplicateSlideCmd struct { + PresentationID string `arg:"" name:"presentationId" help:"Presentation ID"` + SlideID string `arg:"" name:"slideId" help:"Slide object ID to duplicate (use 'slides list-slides' to find IDs)"` + ToIndex *int64 `name:"to-index" help:"Zero-based insertion index for the duplicated slide"` +} + +func (c *SlidesDuplicateSlideCmd) Run(ctx context.Context, flags *RootFlags) error { + u := ui.FromContext(ctx) + + presentationID := strings.TrimSpace(c.PresentationID) + if presentationID == "" { + return usage("empty presentationId") + } + slideID := strings.TrimSpace(c.SlideID) + if slideID == "" { + return usage("empty slideId") + } + if c.ToIndex != nil && *c.ToIndex < 0 { + return usage("--to-index must be >= 0") + } + + duplicateID := newSlidesStructuralObjectID("gogDup") + requests := []*slides.Request{ + { + DuplicateObject: &slides.DuplicateObjectRequest{ + ObjectId: slideID, + ObjectIds: map[string]string{slideID: duplicateID}, + }, + }, + } + if c.ToIndex != nil { + requests = append(requests, &slides.Request{ + UpdateSlidesPosition: &slides.UpdateSlidesPositionRequest{ + SlideObjectIds: []string{duplicateID}, + InsertionIndex: *c.ToIndex, + ForceSendFields: []string{"InsertionIndex"}, + }, + }) + } + body := &slides.BatchUpdatePresentationRequest{Requests: requests} + + payload := map[string]any{ + "presentation_id": presentationID, + "source_slide_object_id": slideID, + "slide_object_id": duplicateID, + "batch_update": body, + } + if c.ToIndex != nil { + payload["to_index"] = *c.ToIndex + } + if err := dryRunExit(ctx, flags, "slides.duplicate-slide", payload); err != nil { + return err + } + + account, err := requireAccount(flags) + if err != nil { + return err + } + slidesSvc, err := slidesService(ctx, account) + if err != nil { + return err + } + if _, err := slidesSvc.Presentations.BatchUpdate(presentationID, body).Context(ctx).Do(); err != nil { + return fmt.Errorf("duplicate slide: %w", err) + } + + if outfmt.IsJSON(ctx) { + out := map[string]any{ + "presentationId": presentationID, + "sourceSlideObjectId": slideID, + "slideObjectId": duplicateID, + "duplicatedSlideObjectId": duplicateID, + } + if c.ToIndex != nil { + out["toIndex"] = *c.ToIndex + } + return outfmt.WriteJSON(ctx, stdoutWriter(ctx), out) + } + + u.Out().Linef("slideObjectId\t%s", duplicateID) + u.Out().Linef("sourceSlideObjectId\t%s", slideID) + u.Out().Linef("presentationId\t%s", presentationID) + if c.ToIndex != nil { + u.Out().Linef("toIndex\t%d", *c.ToIndex) + } + return nil +} + +type SlidesMoveSlideCmd struct { + PresentationID string `arg:"" name:"presentationId" help:"Presentation ID"` + SlideID string `arg:"" name:"slideId" help:"Slide object ID to move (use 'slides list-slides' to find IDs)"` + ToIndex *int64 `name:"to-index" required:"" help:"Zero-based insertion index where the slide should be moved"` +} + +func (c *SlidesMoveSlideCmd) Run(ctx context.Context, flags *RootFlags) error { + u := ui.FromContext(ctx) + + presentationID := strings.TrimSpace(c.PresentationID) + if presentationID == "" { + return usage("empty presentationId") + } + slideID := strings.TrimSpace(c.SlideID) + if slideID == "" { + return usage("empty slideId") + } + if c.ToIndex == nil { + return usage("--to-index is required") + } + if *c.ToIndex < 0 { + return usage("--to-index must be >= 0") + } + + body := &slides.BatchUpdatePresentationRequest{ + Requests: []*slides.Request{ + { + UpdateSlidesPosition: &slides.UpdateSlidesPositionRequest{ + SlideObjectIds: []string{slideID}, + InsertionIndex: *c.ToIndex, + ForceSendFields: []string{"InsertionIndex"}, + }, + }, + }, + } + if err := dryRunExit(ctx, flags, "slides.move-slide", map[string]any{ + "presentation_id": presentationID, + "slide_object_id": slideID, + "to_index": *c.ToIndex, + "batch_update": body, + }); err != nil { + return err + } + + account, err := requireAccount(flags) + if err != nil { + return err + } + slidesSvc, err := slidesService(ctx, account) + if err != nil { + return err + } + if _, err := slidesSvc.Presentations.BatchUpdate(presentationID, body).Context(ctx).Do(); err != nil { + return fmt.Errorf("move slide: %w", err) + } + + if outfmt.IsJSON(ctx) { + return outfmt.WriteJSON(ctx, stdoutWriter(ctx), map[string]any{ + "presentationId": presentationID, + "slideObjectId": slideID, + "toIndex": *c.ToIndex, + }) + } + + u.Out().Linef("slideObjectId\t%s", slideID) + u.Out().Linef("presentationId\t%s", presentationID) + u.Out().Linef("toIndex\t%d", *c.ToIndex) + return nil +} + +func newSlidesStructuralObjectID(prefix string) string { + return fmt.Sprintf("%s%d", prefix, time.Now().UnixNano()) +} + +func slidesStructuralLayoutReference(layout, layoutID string) *slides.LayoutReference { + if layoutID != "" { + return &slides.LayoutReference{LayoutId: layoutID} + } + if layout != "" { + return &slides.LayoutReference{PredefinedLayout: layout} + } + return nil +} + +func slidesStructuralLayoutName(layout string) string { + if layout == "" { + return "BLANK" + } + return layout +} diff --git a/internal/cmd/slides_structural_test.go b/internal/cmd/slides_structural_test.go new file mode 100644 index 000000000..ae4ba37e7 --- /dev/null +++ b/internal/cmd/slides_structural_test.go @@ -0,0 +1,479 @@ +package cmd + +import ( + "bytes" + "context" + "encoding/json" + "io" + "strings" + "testing" + + "google.golang.org/api/slides/v1" +) + +func int64TestPtr(v int64) *int64 { return &v } +func stringTestPtr(v string) *string { return &v } + +func TestSlidesNewSlide(t *testing.T) { + var captured []*slides.Request + srv := mockSlidesBatchUpdateServer(t, &captured, map[string]any{ + "presentationId": "pres1", + "replies": []any{map[string]any{}}, + }) + defer srv.Close() + + svc := newSlidesServiceFromServer(t, srv) + flags := &RootFlags{Account: "a@b.com"} + var out bytes.Buffer + ctx := withSlidesTestService(newCmdRuntimeOutputContext(t, &out, io.Discard), svc) + + cmd := &SlidesNewSlideCmd{ + PresentationID: "pres1", + Layout: stringTestPtr("TITLE_AND_BODY"), + Index: int64TestPtr(0), + } + if err := cmd.Run(ctx, flags); err != nil { + t.Fatalf("Run: %v", err) + } + + if len(captured) != 1 { + t.Fatalf("expected 1 request, got %d", len(captured)) + } + create := captured[0].CreateSlide + if create == nil { + t.Fatalf("expected CreateSlide request, got %+v", captured[0]) + } + if create.ObjectId == "" || !strings.HasPrefix(create.ObjectId, "gogSlide") { + t.Fatalf("unexpected generated slide ID: %q", create.ObjectId) + } + if create.SlideLayoutReference == nil || create.SlideLayoutReference.PredefinedLayout != "TITLE_AND_BODY" { + t.Fatalf("unexpected layout reference: %+v", create.SlideLayoutReference) + } + if create.InsertionIndex != 0 { + t.Fatalf("InsertionIndex = %d, want 0", create.InsertionIndex) + } + if got := strings.TrimSpace(out.String()); !strings.Contains(got, "slideObjectId\tgogSlide") || !strings.Contains(got, "index\t0") { + t.Fatalf("unexpected stdout: %q", out.String()) + } +} + +func TestSlidesNewSlideJSON(t *testing.T) { + var captured []*slides.Request + srv := mockSlidesBatchUpdateServer(t, &captured, map[string]any{ + "presentationId": "pres1", + "replies": []any{map[string]any{}}, + }) + defer srv.Close() + + svc := newSlidesServiceFromServer(t, srv) + flags := &RootFlags{Account: "a@b.com"} + var out bytes.Buffer + ctx := withSlidesTestService(newCmdRuntimeJSONOutputContext(t, &out, io.Discard), svc) + + cmd := &SlidesNewSlideCmd{PresentationID: "pres1", Layout: stringTestPtr("BLANK")} + if err := cmd.Run(ctx, flags); err != nil { + t.Fatalf("Run: %v", err) + } + + var got struct { + PresentationID string `json:"presentationId"` + SlideObjectID string `json:"slideObjectId"` + Layout string `json:"layout"` + } + if err := json.Unmarshal(out.Bytes(), &got); err != nil { + t.Fatalf("JSON parse: %v\noutput: %s", err, out.String()) + } + if got.PresentationID != "pres1" || got.Layout != "BLANK" || !strings.HasPrefix(got.SlideObjectID, "gogSlide") { + t.Fatalf("unexpected JSON output: %#v", got) + } +} + +func TestSlidesNewSlideDefaultsToBlank(t *testing.T) { + var captured []*slides.Request + srv := mockSlidesBatchUpdateServer(t, &captured, map[string]any{ + "presentationId": "pres1", + "replies": []any{map[string]any{}}, + }) + defer srv.Close() + + var out bytes.Buffer + ctx := withSlidesTestService(newCmdRuntimeJSONOutputContext(t, &out, io.Discard), newSlidesServiceFromServer(t, srv)) + if err := (&SlidesNewSlideCmd{PresentationID: "pres1"}).Run(ctx, &RootFlags{Account: "a@b.com"}); err != nil { + t.Fatalf("Run: %v", err) + } + + create := captured[0].CreateSlide + if create == nil || create.SlideLayoutReference != nil { + t.Fatalf("default slide should omit layout reference, got %+v", create) + } + if !strings.Contains(out.String(), `"layout": "BLANK"`) { + t.Fatalf("default output should report BLANK: %s", out.String()) + } +} + +func TestSlidesNewSlideUsesExactLayoutID(t *testing.T) { + var captured []*slides.Request + srv := mockSlidesBatchUpdateServer(t, &captured, map[string]any{ + "presentationId": "pres1", + "replies": []any{map[string]any{}}, + }) + defer srv.Close() + + var out bytes.Buffer + ctx := withSlidesTestService(newCmdRuntimeJSONOutputContext(t, &out, io.Discard), newSlidesServiceFromServer(t, srv)) + cmd := &SlidesNewSlideCmd{PresentationID: "pres1", LayoutID: "layout_123"} + if err := cmd.Run(ctx, &RootFlags{Account: "a@b.com"}); err != nil { + t.Fatalf("Run: %v", err) + } + + create := captured[0].CreateSlide + if create == nil || create.SlideLayoutReference == nil || create.SlideLayoutReference.LayoutId != "layout_123" { + t.Fatalf("unexpected layout reference: %+v", create) + } + if !strings.Contains(out.String(), `"layoutId": "layout_123"`) { + t.Fatalf("unexpected output: %s", out.String()) + } +} + +func TestSlidesNewSlideDryRunSkipsService(t *testing.T) { + flags := &RootFlags{Account: "a@b.com", DryRun: true} + var out bytes.Buffer + ctx := withSlidesTestServiceFactory( + newCmdRuntimeJSONOutputContext(t, &out, io.Discard), + func(context.Context, string) (*slides.Service, error) { + t.Fatal("slides service should not be created during dry-run") + return nil, context.Canceled + }, + ) + + cmd := &SlidesNewSlideCmd{ + PresentationID: "pres1", + Layout: stringTestPtr("TITLE_AND_BODY"), + Index: int64TestPtr(2), + } + if err := cmd.Run(ctx, flags); err != nil && ExitCode(err) != 0 { + t.Fatalf("Run: %v", err) + } + + var got struct { + DryRun bool `json:"dry_run"` + Op string `json:"op"` + Request struct { + BatchUpdate slides.BatchUpdatePresentationRequest `json:"batch_update"` + } `json:"request"` + } + if err := json.Unmarshal(out.Bytes(), &got); err != nil { + t.Fatalf("dry-run output should be valid JSON: %v\nout=%s", err, out.String()) + } + if !got.DryRun || got.Op != "slides.new-slide" { + t.Fatalf("unexpected dry-run envelope: %#v", got) + } + if len(got.Request.BatchUpdate.Requests) != 1 || got.Request.BatchUpdate.Requests[0].CreateSlide == nil { + t.Fatalf("expected CreateSlide dry-run request, got %+v", got.Request.BatchUpdate.Requests) + } +} + +func TestSlidesNewSlideValidation(t *testing.T) { + ctx := withSlidesTestServiceFactory( + newCmdRuntimeOutputContext(t, io.Discard, io.Discard), + func(context.Context, string) (*slides.Service, error) { + t.Fatal("slides service should not be created") + return nil, context.Canceled + }, + ) + + tests := []struct { + name string + cmd SlidesNewSlideCmd + want string + }{ + {"empty presentation", SlidesNewSlideCmd{PresentationID: " ", Layout: stringTestPtr("BLANK")}, "empty presentationId"}, + {"layout conflict", SlidesNewSlideCmd{PresentationID: "pres1", Layout: stringTestPtr("BLANK"), LayoutID: "layout_123"}, "--layout and --layout-id are mutually exclusive"}, + {"negative index", SlidesNewSlideCmd{PresentationID: "pres1", Layout: stringTestPtr("BLANK"), Index: int64TestPtr(-1)}, "--index must be >= 0"}, + } + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + err := tc.cmd.Run(ctx, &RootFlags{Account: "a@b.com"}) + if err == nil || !strings.Contains(err.Error(), tc.want) { + t.Fatalf("expected %q error, got %v", tc.want, err) + } + if got := ExitCode(err); got != 2 { + t.Fatalf("ExitCode = %d, want 2 (err=%v)", got, err) + } + }) + } +} + +func TestSlidesDuplicateSlide(t *testing.T) { + var captured []*slides.Request + srv := mockSlidesBatchUpdateServer(t, &captured, map[string]any{ + "presentationId": "pres1", + "replies": []any{map[string]any{}, map[string]any{}}, + }) + defer srv.Close() + + svc := newSlidesServiceFromServer(t, srv) + flags := &RootFlags{Account: "a@b.com"} + var out bytes.Buffer + ctx := withSlidesTestService(newCmdRuntimeOutputContext(t, &out, io.Discard), svc) + + cmd := &SlidesDuplicateSlideCmd{ + PresentationID: "pres1", + SlideID: "slide_1", + ToIndex: int64TestPtr(0), + } + if err := cmd.Run(ctx, flags); err != nil { + t.Fatalf("Run: %v", err) + } + + if len(captured) != 2 { + t.Fatalf("expected duplicate and move requests, got %d", len(captured)) + } + dup := captured[0].DuplicateObject + if dup == nil { + t.Fatalf("expected DuplicateObject request, got %+v", captured[0]) + } + duplicateID := dup.ObjectIds["slide_1"] + if dup.ObjectId != "slide_1" || !strings.HasPrefix(duplicateID, "gogDup") { + t.Fatalf("unexpected duplicate request: %+v", dup) + } + move := captured[1].UpdateSlidesPosition + if move == nil { + t.Fatalf("expected UpdateSlidesPosition request, got %+v", captured[1]) + } + if len(move.SlideObjectIds) != 1 || move.SlideObjectIds[0] != duplicateID || move.InsertionIndex != 0 { + t.Fatalf("unexpected move request: %+v", move) + } + if got := out.String(); !strings.Contains(got, "slideObjectId\t"+duplicateID) || !strings.Contains(got, "toIndex\t0") { + t.Fatalf("unexpected stdout: %q", got) + } +} + +func TestSlidesDuplicateSlideWithoutMove(t *testing.T) { + var captured []*slides.Request + srv := mockSlidesBatchUpdateServer(t, &captured, map[string]any{ + "presentationId": "pres1", + "replies": []any{map[string]any{}}, + }) + defer srv.Close() + + svc := newSlidesServiceFromServer(t, srv) + flags := &RootFlags{Account: "a@b.com"} + ctx := withSlidesTestService(newCmdRuntimeOutputContext(t, io.Discard, io.Discard), svc) + + cmd := &SlidesDuplicateSlideCmd{PresentationID: "pres1", SlideID: "slide_1"} + if err := cmd.Run(ctx, flags); err != nil { + t.Fatalf("Run: %v", err) + } + if len(captured) != 1 || captured[0].DuplicateObject == nil { + t.Fatalf("expected one DuplicateObject request, got %+v", captured) + } +} + +func TestSlidesDuplicateSlideDryRunSkipsService(t *testing.T) { + var out bytes.Buffer + ctx := withSlidesTestServiceFactory( + newCmdRuntimeJSONOutputContext(t, &out, io.Discard), + func(context.Context, string) (*slides.Service, error) { + t.Fatal("slides service should not be created during dry-run") + return nil, context.Canceled + }, + ) + cmd := &SlidesDuplicateSlideCmd{ + PresentationID: "pres1", + SlideID: "slide_1", + ToIndex: int64TestPtr(0), + } + if err := cmd.Run(ctx, &RootFlags{Account: "a@b.com", DryRun: true}); err != nil && ExitCode(err) != 0 { + t.Fatalf("Run: %v", err) + } + + var got struct { + Op string `json:"op"` + Request struct { + BatchUpdate slides.BatchUpdatePresentationRequest `json:"batch_update"` + } `json:"request"` + } + if err := json.Unmarshal(out.Bytes(), &got); err != nil { + t.Fatalf("decode dry-run output: %v\n%s", err, out.String()) + } + if got.Op != "slides.duplicate-slide" || len(got.Request.BatchUpdate.Requests) != 2 { + t.Fatalf("unexpected dry-run output: %+v", got) + } + if got.Request.BatchUpdate.Requests[0].DuplicateObject == nil || got.Request.BatchUpdate.Requests[1].UpdateSlidesPosition == nil { + t.Fatalf("unexpected dry-run requests: %+v", got.Request.BatchUpdate.Requests) + } +} + +func TestSlidesDuplicateSlideValidation(t *testing.T) { + ctx := withSlidesTestServiceFactory( + newCmdRuntimeOutputContext(t, io.Discard, io.Discard), + func(context.Context, string) (*slides.Service, error) { + t.Fatal("slides service should not be created") + return nil, context.Canceled + }, + ) + + tests := []struct { + name string + cmd SlidesDuplicateSlideCmd + want string + }{ + {"empty presentation", SlidesDuplicateSlideCmd{PresentationID: " ", SlideID: "slide_1"}, "empty presentationId"}, + {"empty slide", SlidesDuplicateSlideCmd{PresentationID: "pres1", SlideID: " "}, "empty slideId"}, + {"negative to index", SlidesDuplicateSlideCmd{PresentationID: "pres1", SlideID: "slide_1", ToIndex: int64TestPtr(-1)}, "--to-index must be >= 0"}, + } + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + err := tc.cmd.Run(ctx, &RootFlags{Account: "a@b.com"}) + if err == nil || !strings.Contains(err.Error(), tc.want) { + t.Fatalf("expected %q error, got %v", tc.want, err) + } + if got := ExitCode(err); got != 2 { + t.Fatalf("ExitCode = %d, want 2 (err=%v)", got, err) + } + }) + } +} + +func TestSlidesMoveSlide(t *testing.T) { + var captured []*slides.Request + srv := mockSlidesBatchUpdateServer(t, &captured, map[string]any{ + "presentationId": "pres1", + "replies": []any{map[string]any{}}, + }) + defer srv.Close() + + svc := newSlidesServiceFromServer(t, srv) + flags := &RootFlags{Account: "a@b.com"} + var out bytes.Buffer + ctx := withSlidesTestService(newCmdRuntimeOutputContext(t, &out, io.Discard), svc) + + cmd := &SlidesMoveSlideCmd{ + PresentationID: "pres1", + SlideID: "slide_1", + ToIndex: int64TestPtr(3), + } + if err := cmd.Run(ctx, flags); err != nil { + t.Fatalf("Run: %v", err) + } + + if len(captured) != 1 { + t.Fatalf("expected 1 request, got %d", len(captured)) + } + move := captured[0].UpdateSlidesPosition + if move == nil { + t.Fatalf("expected UpdateSlidesPosition request, got %+v", captured[0]) + } + if len(move.SlideObjectIds) != 1 || move.SlideObjectIds[0] != "slide_1" || move.InsertionIndex != 3 { + t.Fatalf("unexpected move request: %+v", move) + } + if got := out.String(); !strings.Contains(got, "slideObjectId\tslide_1") || !strings.Contains(got, "toIndex\t3") { + t.Fatalf("unexpected stdout: %q", got) + } +} + +func TestSlidesMoveSlideJSON(t *testing.T) { + var captured []*slides.Request + srv := mockSlidesBatchUpdateServer(t, &captured, map[string]any{ + "presentationId": "pres1", + "replies": []any{map[string]any{}}, + }) + defer srv.Close() + + svc := newSlidesServiceFromServer(t, srv) + flags := &RootFlags{Account: "a@b.com"} + var out bytes.Buffer + ctx := withSlidesTestService(newCmdRuntimeJSONOutputContext(t, &out, io.Discard), svc) + + cmd := &SlidesMoveSlideCmd{ + PresentationID: "pres1", + SlideID: "slide_1", + ToIndex: int64TestPtr(2), + } + if err := cmd.Run(ctx, flags); err != nil { + t.Fatalf("Run: %v", err) + } + + var got struct { + PresentationID string `json:"presentationId"` + SlideObjectID string `json:"slideObjectId"` + ToIndex int64 `json:"toIndex"` + } + if err := json.Unmarshal(out.Bytes(), &got); err != nil { + t.Fatalf("JSON parse: %v\noutput: %s", err, out.String()) + } + if got.PresentationID != "pres1" || got.SlideObjectID != "slide_1" || got.ToIndex != 2 { + t.Fatalf("unexpected JSON output: %#v", got) + } +} + +func TestSlidesMoveSlideDryRunSkipsService(t *testing.T) { + flags := &RootFlags{Account: "a@b.com", DryRun: true} + var out bytes.Buffer + ctx := withSlidesTestServiceFactory( + newCmdRuntimeJSONOutputContext(t, &out, io.Discard), + func(context.Context, string) (*slides.Service, error) { + t.Fatal("slides service should not be created during dry-run") + return nil, context.Canceled + }, + ) + + cmd := &SlidesMoveSlideCmd{ + PresentationID: "pres1", + SlideID: "slide_1", + ToIndex: int64TestPtr(0), + } + if err := cmd.Run(ctx, flags); err != nil && ExitCode(err) != 0 { + t.Fatalf("Run: %v", err) + } + + var got struct { + DryRun bool `json:"dry_run"` + Op string `json:"op"` + Request struct { + BatchUpdate slides.BatchUpdatePresentationRequest `json:"batch_update"` + } `json:"request"` + } + if err := json.Unmarshal(out.Bytes(), &got); err != nil { + t.Fatalf("dry-run output should be valid JSON: %v\nout=%s", err, out.String()) + } + if !got.DryRun || got.Op != "slides.move-slide" { + t.Fatalf("unexpected dry-run envelope: %#v", got) + } + if len(got.Request.BatchUpdate.Requests) != 1 || got.Request.BatchUpdate.Requests[0].UpdateSlidesPosition == nil { + t.Fatalf("expected UpdateSlidesPosition dry-run request, got %+v", got.Request.BatchUpdate.Requests) + } +} + +func TestSlidesMoveSlideValidation(t *testing.T) { + ctx := withSlidesTestServiceFactory( + newCmdRuntimeOutputContext(t, io.Discard, io.Discard), + func(context.Context, string) (*slides.Service, error) { + t.Fatal("slides service should not be created") + return nil, context.Canceled + }, + ) + + tests := []struct { + name string + cmd SlidesMoveSlideCmd + want string + }{ + {"empty presentation", SlidesMoveSlideCmd{PresentationID: " ", SlideID: "slide_1", ToIndex: int64TestPtr(0)}, "empty presentationId"}, + {"empty slide", SlidesMoveSlideCmd{PresentationID: "pres1", SlideID: " ", ToIndex: int64TestPtr(0)}, "empty slideId"}, + {"missing to index", SlidesMoveSlideCmd{PresentationID: "pres1", SlideID: "slide_1"}, "--to-index is required"}, + {"negative to index", SlidesMoveSlideCmd{PresentationID: "pres1", SlideID: "slide_1", ToIndex: int64TestPtr(-1)}, "--to-index must be >= 0"}, + } + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + err := tc.cmd.Run(ctx, &RootFlags{Account: "a@b.com"}) + if err == nil || !strings.Contains(err.Error(), tc.want) { + t.Fatalf("expected %q error, got %v", tc.want, err) + } + if got := ExitCode(err); got != 2 { + t.Fatalf("ExitCode = %d, want 2 (err=%v)", got, err) + } + }) + } +}