diff --git a/openspec/changes/cap-generated-slug-length/proposal.md b/openspec/changes/cap-generated-slug-length/proposal.md new file mode 100644 index 0000000..32e42dc --- /dev/null +++ b/openspec/changes/cap-generated-slug-length/proposal.md @@ -0,0 +1,72 @@ +# Cap the length of generated slugs + +## Context + +`slugify` (`pull.go:485`) turns a title into kebab-case with no length limit at +all — it emits one hyphen-joined token per word for the entire input. Every +generated change name is therefore as long as whatever it was derived from. + +Observed in real use: pulling ExopenGitHub/portal#4331, whose title is + +> Multi-entity integrations: "Create all" has no selection UI, and company-name +> display is inconsistent across pages + +produced the directory + +``` +openspec/changes/multi-entity-integrations-create-all-has-no-selection-ui-and-company-name-display-is-inconsistent-across-pages +``` + +98 characters, which then has to be typed for every `-change` flag, shows up in +every path in every log line, and had to be renamed by hand immediately. Compare +against the slugs this repo actually uses — `add-planning-scan`, +`add-worktree-support`, `add-agent-help-command` — all short, verb-led, and +hand-picked. The generated ones do not resemble them. + +Three call sites are affected, in increasing severity: + +- `pull.go:93` — issue title. Long titles are common; a descriptive issue title + is good practice and is punished here. +- `epic.go:83` — epic title, prefixed `epic:`. Same problem. +- `spinoff.go:92` — **task text**. This is the worst case: task lines are full + sentences, often with trailing detail, so spinoff slugs are longer still. + +There is no test for `slugify` anywhere in the repo. + +## Why not just pass `-change` + +`pull` already accepts `-change`, and `slugFromMarker` can read a slug out of +the body, so a workaround exists. But the default path is what agents and +newcomers hit first, it produces something nobody would choose by hand, and +`spinoff` has no equivalent escape hatch for its per-task slugs. + +## Proposed change + +Cap generated slugs at a sane length, truncating on a word boundary rather than +mid-word, and never leaving a trailing hyphen. + +Suggested shape, to be confirmed by whoever picks this up: + +- A single `maxSlugLen` constant (proposal: 48 characters — long enough for + `add-agent-help-command`-style names with room to spare, short enough to type). +- Truncate at the last hyphen at or before the cap, so words stay whole. If the + first word alone exceeds the cap, hard-truncate it rather than emit nothing. +- Apply inside `slugify` so all three call sites benefit, rather than at each + call site. +- `epic.go` prefixes `epic:` before the slug — decide whether the cap covers the + prefix or only the slug portion, and be explicit about it. + +## Collision risk + +Truncation can make two distinct titles collide. Worth deciding deliberately: + +- `pull` already handles an existing change directory somehow — check what it + does today and whether truncation makes that path more likely, rather than + assuming it is fine. +- A numeric suffix on collision (`-2`) is the obvious fallback, but only if the + existing behaviour does not already cover it. + +## Out of scope + +Changing the slugs of changes that already exist. This only affects newly +generated ones. diff --git a/openspec/changes/cap-generated-slug-length/tasks.md b/openspec/changes/cap-generated-slug-length/tasks.md new file mode 100644 index 0000000..17ab315 --- /dev/null +++ b/openspec/changes/cap-generated-slug-length/tasks.md @@ -0,0 +1,71 @@ +# Tasks + +## Decide first + +- [x] Pick `maxSlugLen`. Proposal: 48. Sanity-checked against this repo's own + slugs (`add-agent-help-command` is 22; the longest existing ones sit + well under 40) — 48 confirmed. +- [x] Decide whether `epic.go`'s `epic:` prefix counts toward the cap. + Decision: no — the cap applies inside `slugify` to the generated slug + only; `epic.go` prefixes `epic:` afterward, so the prefix is fixed + overhead on top of an already-capped, readable slug (documented as a + comment on `maxSlugLen`). +- [x] Check what `pull` does today when the target change directory already + exists, and decide whether truncation needs a collision suffix or the + existing behaviour already covers it. + Finding: `pull.go` had **no existing collision guard at all** — + `os.MkdirAll` + `os.WriteFile` silently overwrote `proposal.md`/ + `tasks.md` in place whenever the derived slug matched an existing + directory, regardless of whether it was the same issue being re-pulled + or a different one that happened to collide. This was a pre-existing + gap independent of slug length: two different issues with literally + identical titles already collided, untruncated. Truncation raises the + odds (demonstrated by `TestSlugifyDistinctLongTitlesCanCollideOnTruncation`) + but doesn't create a new class of bug on its own — it just makes an + existing one more likely, so it's fixed here rather than deferred. + **Fixed** in `pull.go`: a directory that already exists is only ever + overwritten when it's an intentional re-pull of the *same* issue (its + ref cache names that issue), or an explicit `-change` into a directory + with no ref cache at all (a hand-authored, never-synced local change — + the human named that exact directory; this is the normal + spec-first-then-pull flow, see `TestPullRecordsTaskBase`). Every other + case — a ref cache naming a *different* issue, or an auto-derived slug + colliding with an untracked hand-authored directory — gets a numeric + suffix (`-2`, `-3`, ...) via `nextAvailableSlug`, or an explicit error + for an explicit `-change` conflicting with a different tracked issue + (pass a different `-change`, or use `specsync adopt`). `spinoff.go`'s + own pre-existing error-on-collision is unaffected. `epic.go`'s slug is + not used as a directory path, so no collision surface there. + +## Implement + +- [x] Add the cap inside `slugify` (`pull.go:485`) so all three call sites get + it: `pull.go:93`, `epic.go:83`, `spinoff.go:92`. +- [x] Truncate at the last hyphen at or before the cap so words stay whole. +- [x] Never emit a trailing or leading hyphen after truncation. +- [x] Hard-truncate when the first word alone exceeds the cap, rather than + returning an empty slug — `pull` errors out on an empty slug, so this + would turn a long single-word title into a failure. + +## Test + +`slugify` currently has no tests at all. + +- [x] Table test: normal short title unchanged; long title truncated on a word + boundary; title whose first word exceeds the cap; title of only + punctuation (already returns empty — lock that in); leading/trailing + punctuation; consecutive punctuation runs. +- [x] Regression case from the report: the 103-character portal#4331 title + should produce something short and readable. +- [x] Collision-risk case: two distinct long titles that truncate to the same + stem (`TestSlugifyDistinctLongTitlesCanCollideOnTruncation`) — documents + that `slugify` itself doesn't dedupe (it's a pure string function); the + directory-level collision this enables is what `pull_collision_test.go` + now covers directly (same-issue re-pull overwrites in place; + different-issue collision on a generated slug gets suffixed; + explicit `-change` collision with a different tracked issue errors; + explicit `-change` collision with an untracked hand-authored directory + still overwrites; an auto-derived slug colliding with an untracked + hand-authored directory gets suffixed; `-dry-run` previews the + suffixed slug without writing; `nextAvailableSlug` skips multiple + taken suffixes). diff --git a/pull.go b/pull.go index 0058345..0fba1f6 100644 --- a/pull.go +++ b/pull.go @@ -96,6 +96,37 @@ func Pull(ctx context.Context, opts PullOptions) (PullResult, error) { return PullResult{}, fmt.Errorf("could not derive a change name from issue %s; pass -change", opts.IssueID) } + // A slug collision (two issues landing on the same directory — a + // generated slug and, more rarely, an explicit -change) must not + // silently overwrite an unrelated change's proposal.md/tasks.md. + // + // Two cases are left alone (existing behavior): re-pulling the *same* + // issue into its own existing directory (the intentional refresh path), + // and an explicit -change into a directory with no ref cache at all + // (a hand-authored, never-synced local change — the human named this + // exact directory, and binding an issue's content into it is the normal + // spec-first-then-pull flow; see TestPullRecordsTaskBase). + // + // A genuine conflict — the directory's ref cache names a *different* + // issue — refuses an explicit -change (pass a different one, or + // `specsync adopt`) and suffixes an auto-derived one instead of + // clobbering the other change. An auto-derived slug colliding with an + // untracked hand-authored directory is also suffixed, since nothing + // asked for that specific name. + if candidateDir := filepath.Join(opts.OpenSpecDir, "changes", slug); dirExists(candidateDir) { + existing, _ := LoadRefs(candidateDir) + conflictsWithDifferentIssue := len(existing) > 0 && !refsContainIssue(existing, issueID) + collidesWithUntrackedDir := len(existing) == 0 && opts.Slug == "" + if conflictsWithDifferentIssue { + if opts.Slug != "" { + return PullResult{}, fmt.Errorf("pull: change %q already exists and is not linked to issue %s; pass a different -change or use `specsync adopt`", slug, issueID) + } + slug = nextAvailableSlug(opts.OpenSpecDir, slug) + } else if collidesWithUntrackedDir { + slug = nextAvailableSlug(opts.OpenSpecDir, slug) + } + } + proposal, tasks, relatedURLs, origAsk, design, disc := splitBody(item.Body, item.Title) // The body may carry only a linked stub for an overflowed design.md; // recover the real content from the comment when supported. @@ -480,8 +511,17 @@ func slugFromMarker(body string) string { return strings.TrimSpace(rest[:j]) } +// maxSlugLen caps a generated slug's length. Long enough for +// add-agent-help-command-style names with room to spare, short enough to +// type as a -change flag and to keep in every log line and path. Applies to +// the slug itself, not to a prefix a caller adds afterward (e.g. epic.go's +// "epic:" marker prefix) — the prefix is fixed overhead, not part of the +// generated name. +const maxSlugLen = 48 + // slugify turns a title into a kebab-case slug: lowercase, with each run of -// non-alphanumeric characters collapsed to a single hyphen and trimmed. +// non-alphanumeric characters collapsed to a single hyphen and trimmed, then +// capped at maxSlugLen. func slugify(s string) string { var b strings.Builder pendingHyphen := false @@ -497,5 +537,59 @@ func slugify(s string) string { pendingHyphen = true } } - return b.String() + return capSlug(b.String(), maxSlugLen) +} + +// capSlug truncates s to at most max characters, preferring a word boundary +// (the last hyphen at or before the cut) so truncation never splits a word. +// When the first word alone exceeds max, it hard-truncates instead of +// returning "" — an empty slug is treated as failure by every caller. +func capSlug(s string, max int) string { + if len(s) <= max { + return s + } + cut := s[:max] + if i := strings.LastIndexByte(cut, '-'); i > 0 { + cut = cut[:i] + } + return strings.TrimRight(cut, "-") +} + +// dirExists reports whether path exists (any type), for the pull collision +// guard below. +func dirExists(path string) bool { + _, err := os.Stat(path) + return err == nil +} + +// refsContainIssue reports whether any cached ref in refs points at issueID — +// i.e. the existing directory tracks the very issue being pulled, making this +// an intentional refresh rather than a collision. +func refsContainIssue(refs map[string]Ref, issueID string) bool { + for _, r := range refs { + if r.ID == issueID { + return true + } + } + return false +} + +// nextAvailableSlug finds the first "-2", "-3", ... whose change +// directory doesn't exist yet, trimming base to keep the result within +// maxSlugLen when the numeric suffix would otherwise push it over. +func nextAvailableSlug(openspecDir, base string) string { + for i := 2; i < 1000; i++ { + suffix := fmt.Sprintf("-%d", i) + candidate := base + suffix + if len(candidate) > maxSlugLen { + candidate = capSlug(base, maxSlugLen-len(suffix)) + suffix + } + if !dirExists(filepath.Join(openspecDir, "changes", candidate)) { + return candidate + } + } + // Exhausted a generous range of suffixes — fall back to the last one + // tried rather than looping forever; pull will simply overwrite it, + // same as today's pre-existing behavior. + return fmt.Sprintf("%s-999", base) } diff --git a/pull_collision_test.go b/pull_collision_test.go new file mode 100644 index 0000000..3a27e68 --- /dev/null +++ b/pull_collision_test.go @@ -0,0 +1,166 @@ +package specsync + +import ( + "context" + "path/filepath" + "strings" + "testing" +) + +func TestPull_SameIssueRePullOverwritesInPlace(t *testing.T) { + dir := t.TempDir() + issue := fakeIssue{Number: 7, URL: "https://github.com/o/r/issues/7", Title: "Round trip", State: "open", Body: "# Round trip\n\nfirst body\n"} + var calls [][]string + prov := NewGitHubProviderFuncWithRepo("o/r", ghRunner(issue, &calls)) + + first, err := Pull(context.Background(), PullOptions{OpenSpecDir: dir, Provider: prov, IssueID: "7"}) + if err != nil { + t.Fatalf("first Pull: %v", err) + } + + issue.Body = "# Round trip\n\nupdated body\n" + second, err := Pull(context.Background(), PullOptions{OpenSpecDir: dir, Provider: prov, IssueID: "7"}) + if err != nil { + t.Fatalf("second Pull (re-pull of the same issue): %v", err) + } + if second.Slug != first.Slug { + t.Fatalf("re-pulling the same issue must reuse the same slug, got %q then %q", first.Slug, second.Slug) + } + if second.Dir != first.Dir { + t.Fatalf("re-pulling the same issue must reuse the same directory, got %q then %q", first.Dir, second.Dir) + } +} + +func TestPull_DifferentIssueSameGeneratedSlugGetsSuffixed(t *testing.T) { + dir := t.TempDir() + issueA := fakeIssue{Number: 1, URL: "https://github.com/o/r/issues/1", Title: "Add thing", State: "open", Body: "# Add thing\n\nfirst\n"} + var callsA [][]string + provA := NewGitHubProviderFuncWithRepo("o/r", ghRunner(issueA, &callsA)) + first, err := Pull(context.Background(), PullOptions{OpenSpecDir: dir, Provider: provA, IssueID: "1"}) + if err != nil { + t.Fatalf("first Pull: %v", err) + } + if first.Slug != "add-thing" { + t.Fatalf("expected slug 'add-thing', got %q", first.Slug) + } + + // A different issue whose title happens to slugify identically. + issueB := fakeIssue{Number: 2, URL: "https://github.com/o/r/issues/2", Title: "Add thing", State: "open", Body: "# Add thing\n\nunrelated\n"} + var callsB [][]string + provB := NewGitHubProviderFuncWithRepo("o/r", ghRunner(issueB, &callsB)) + second, err := Pull(context.Background(), PullOptions{OpenSpecDir: dir, Provider: provB, IssueID: "2"}) + if err != nil { + t.Fatalf("second Pull (colliding slug, different issue): %v", err) + } + if second.Slug == first.Slug { + t.Fatalf("expected a suffixed slug distinct from %q, got the same", first.Slug) + } + if second.Slug != "add-thing-2" { + t.Errorf("expected 'add-thing-2', got %q", second.Slug) + } + + // Neither change's own content was clobbered. + firstProposal := readFile(t, filepath.Join(dir, "changes", "add-thing", "proposal.md")) + if !strings.Contains(firstProposal, "first") { + t.Errorf("first change's proposal.md was overwritten: %s", firstProposal) + } +} + +func TestPull_ExplicitChangeCollisionWithDifferentIssueErrors(t *testing.T) { + dir := t.TempDir() + issueA := fakeIssue{Number: 1, URL: "https://github.com/o/r/issues/1", Title: "Add thing", State: "open", Body: "# Add thing\n\nbody\n"} + var callsA [][]string + provA := NewGitHubProviderFuncWithRepo("o/r", ghRunner(issueA, &callsA)) + if _, err := Pull(context.Background(), PullOptions{OpenSpecDir: dir, Provider: provA, IssueID: "1", Slug: "my-change"}); err != nil { + t.Fatalf("first Pull: %v", err) + } + + issueB := fakeIssue{Number: 2, URL: "https://github.com/o/r/issues/2", Title: "Something else", State: "open", Body: "# Something else\n\nbody\n"} + var callsB [][]string + provB := NewGitHubProviderFuncWithRepo("o/r", ghRunner(issueB, &callsB)) + _, err := Pull(context.Background(), PullOptions{OpenSpecDir: dir, Provider: provB, IssueID: "2", Slug: "my-change"}) + if err == nil { + t.Fatal("expected an error pulling a different issue into an explicit, already-taken -change slug") + } + if !strings.Contains(err.Error(), "already exists") { + t.Errorf("unexpected error: %v", err) + } +} + +func TestPull_ExplicitChangeCollisionWithSameIssueOverwrites(t *testing.T) { + dir := t.TempDir() + issue := fakeIssue{Number: 1, URL: "https://github.com/o/r/issues/1", Title: "Add thing", State: "open", Body: "# Add thing\n\nfirst\n"} + var calls [][]string + prov := NewGitHubProviderFuncWithRepo("o/r", ghRunner(issue, &calls)) + if _, err := Pull(context.Background(), PullOptions{OpenSpecDir: dir, Provider: prov, IssueID: "1", Slug: "my-change"}); err != nil { + t.Fatalf("first Pull: %v", err) + } + + issue.Body = "# Add thing\n\nupdated\n" + res, err := Pull(context.Background(), PullOptions{OpenSpecDir: dir, Provider: prov, IssueID: "1", Slug: "my-change"}) + if err != nil { + t.Fatalf("re-pull with explicit -change matching the same issue must succeed: %v", err) + } + if res.Slug != "my-change" { + t.Errorf("expected slug unchanged at 'my-change', got %q", res.Slug) + } +} + +func TestPull_UnrelatedHandAuthoredDirectorySameSlugGetsSuffixed(t *testing.T) { + dir := t.TempDir() + // A hand-authored change with no ref cache at all, sharing the slug an + // incoming issue would generate. + mustWrite(t, filepath.Join(dir, "changes", "add-thing", "proposal.md"), "# Add thing (hand-authored)\n") + + issue := fakeIssue{Number: 9, URL: "https://github.com/o/r/issues/9", Title: "Add thing", State: "open", Body: "# Add thing\n\nfrom issue\n"} + var calls [][]string + prov := NewGitHubProviderFuncWithRepo("o/r", ghRunner(issue, &calls)) + res, err := Pull(context.Background(), PullOptions{OpenSpecDir: dir, Provider: prov, IssueID: "9"}) + if err != nil { + t.Fatalf("Pull: %v", err) + } + if res.Slug != "add-thing-2" { + t.Errorf("expected the incoming pull to get suffixed rather than overwrite the hand-authored change, got %q", res.Slug) + } + + handAuthored := readFile(t, filepath.Join(dir, "changes", "add-thing", "proposal.md")) + if !strings.Contains(handAuthored, "hand-authored") { + t.Errorf("hand-authored change was overwritten: %s", handAuthored) + } +} + +func TestPull_DryRunPreviewsTheSuffixedSlugWithoutWriting(t *testing.T) { + dir := t.TempDir() + issueA := fakeIssue{Number: 1, URL: "https://github.com/o/r/issues/1", Title: "Add thing", State: "open", Body: "# Add thing\n\nfirst\n"} + var callsA [][]string + provA := NewGitHubProviderFuncWithRepo("o/r", ghRunner(issueA, &callsA)) + if _, err := Pull(context.Background(), PullOptions{OpenSpecDir: dir, Provider: provA, IssueID: "1"}); err != nil { + t.Fatalf("first Pull: %v", err) + } + + issueB := fakeIssue{Number: 2, URL: "https://github.com/o/r/issues/2", Title: "Add thing", State: "open", Body: "# Add thing\n\nsecond\n"} + var callsB [][]string + provB := NewGitHubProviderFuncWithRepo("o/r", ghRunner(issueB, &callsB)) + res, err := Pull(context.Background(), PullOptions{OpenSpecDir: dir, Provider: provB, IssueID: "2", DryRun: true}) + if err != nil { + t.Fatalf("dry-run Pull: %v", err) + } + if res.Slug != "add-thing-2" { + t.Errorf("expected dry-run to preview the suffixed slug, got %q", res.Slug) + } + if dirExists(filepath.Join(dir, "changes", "add-thing-2")) { + t.Error("dry-run must not create the suffixed directory") + } +} + +func TestNextAvailableSlug_SkipsMultipleTakenSuffixes(t *testing.T) { + dir := t.TempDir() + mustWrite(t, filepath.Join(dir, "changes", "x", "proposal.md"), "# X\n") + mustWrite(t, filepath.Join(dir, "changes", "x-2", "proposal.md"), "# X2\n") + mustWrite(t, filepath.Join(dir, "changes", "x-3", "proposal.md"), "# X3\n") + + got := nextAvailableSlug(dir, "x") + if got != "x-4" { + t.Errorf("nextAvailableSlug = %q, want x-4", got) + } +} diff --git a/pull_test.go b/pull_test.go index a7dd222..5388dcc 100644 --- a/pull_test.go +++ b/pull_test.go @@ -644,12 +644,24 @@ func TestPull_RequiresIssueForNonGithub(t *testing.T) { type stubNonGithubProvider struct{} -func (s *stubNonGithubProvider) Name() string { return "beads" } -func (s *stubNonGithubProvider) Find(context.Context, string) (*Ref, error) { return nil, nil } -func (s *stubNonGithubProvider) Push(context.Context, WorkItem, *Ref) (Ref, error) { return Ref{}, nil } -func (s *stubNonGithubProvider) Get(context.Context, string) (FetchedItem, error) { return FetchedItem{}, nil } +func (s *stubNonGithubProvider) Name() string { return "beads" } +func (s *stubNonGithubProvider) Find(context.Context, string) (*Ref, error) { return nil, nil } +func (s *stubNonGithubProvider) Push(context.Context, WorkItem, *Ref) (Ref, error) { return Ref{}, nil } +func (s *stubNonGithubProvider) Get(context.Context, string) (FetchedItem, error) { + return FetchedItem{}, nil +} func TestPull_CannotResolveFromBranch(t *testing.T) { + // Force a branch name that never matches the feat/- pattern, + // regardless of what branch this test suite actually happens to be + // checked out on (e.g. a real feat/- branch, which would + // otherwise make this test try to resolve issue for real and fail + // with an unrelated JSON-parse error instead of the branch-mismatch + // error it's meant to assert on). + prev := currentBranchFn + currentBranchFn = func() (string, error) { return "main", nil } + defer func() { currentBranchFn = prev }() + dir := t.TempDir() // On a branch that doesn't match the pattern, Pull should error. _, err := Pull(context.Background(), PullOptions{ diff --git a/slugify_test.go b/slugify_test.go new file mode 100644 index 0000000..9d1f3ca --- /dev/null +++ b/slugify_test.go @@ -0,0 +1,106 @@ +package specsync + +import "testing" + +func TestSlugify(t *testing.T) { + tests := []struct { + name string + title string + want string + }{ + { + name: "normal short title unchanged", + title: "Add worktree support", + want: "add-worktree-support", + }, + { + name: "long title truncated on a word boundary", + title: "Multi-entity integrations: Create all has no selection UI and company name display is inconsistent across pages", + want: "multi-entity-integrations-create-all-has-no", + }, + { + name: "regression case from the report (portal#4331, 103 chars)", + title: `Multi-entity integrations: "Create all" has no selection UI, and company-name display is inconsistent across pages`, + want: "multi-entity-integrations-create-all-has-no", + }, + { + name: "first word alone exceeds the cap: hard-truncate rather than empty", + title: "Supercalifragilisticexpialidocioussupercalifragilisticexpialidocious even more trailing words", + want: "supercalifragilisticexpialidocioussupercalifragi", + }, + { + name: "only punctuation returns empty", + title: "!!! --- ???", + want: "", + }, + { + name: "leading and trailing punctuation trimmed", + title: "--Fix login--", + want: "fix-login", + }, + { + name: "consecutive punctuation runs collapse to one hyphen", + title: "foo ---___ bar", + want: "foo-bar", + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := slugify(tt.title) + if got != tt.want { + t.Errorf("slugify(%q) = %q, want %q", tt.title, got, tt.want) + } + if len(got) > maxSlugLen { + t.Errorf("slugify(%q) = %q, length %d exceeds maxSlugLen %d", tt.title, got, len(got), maxSlugLen) + } + }) + } +} + +func TestSlugifyNeverEmitsTrailingOrLeadingHyphen(t *testing.T) { + titles := []string{ + "a title exactly at the boundary of forty eight characters here", + "---leading and trailing---", + "word-boundary-cutoff-should-not-leave-a-trailing-hyphen-behind", + } + for _, title := range titles { + got := slugify(title) + if got == "" { + continue + } + if got[0] == '-' || got[len(got)-1] == '-' { + t.Errorf("slugify(%q) = %q has a leading or trailing hyphen", title, got) + } + } +} + +func TestSlugifyDistinctLongTitlesCanCollideOnTruncation(t *testing.T) { + a := slugify("Add field forms for the portal onboarding wizard step one only") + b := slugify("Add field forms for the portal onboarding wizard step one plus extra") + if a != b { + t.Skip("these two examples happen not to collide; collision is a known, accepted risk of truncation, not a guaranteed property") + } +} + +func TestCapSlug(t *testing.T) { + tests := []struct { + name string + in string + max int + want string + }{ + {"under cap unchanged", "short-slug", 48, "short-slug"}, + {"exact cap unchanged", "exactly-forty-eight-characters-long-slug-abcdef", 48, "exactly-forty-eight-characters-long-slug-abcdef"}, + {"truncate at word boundary", "one-two-three-four-five-six-seven-eight-nine-ten-eleven", 20, "one-two-three-four"}, + {"single word exceeding cap hard-truncates", "supercalifragilisticexpialidocious", 10, "supercalif"}, + {"truncation landing on a hyphen trims it", "abcde-fghij-klmno", 11, "abcde"}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := capSlug(tt.in, tt.max) + if got != tt.want { + t.Errorf("capSlug(%q, %d) = %q, want %q", tt.in, tt.max, got, tt.want) + } + }) + } +}