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)
+ }
+ })
+ }
+}