feat: slug generation and directory collision resolution

Slugify, AutoTitle, DirName, ResolveDir with -2, -3 suffixing. Full test coverage.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-04-05 18:30:06 -04:00
parent 514f2d8233
commit 7b75cb5f3d
2 changed files with 144 additions and 0 deletions
+50
View File
@@ -0,0 +1,50 @@
package workspace
import (
"fmt"
"os"
"path/filepath"
"regexp"
"strings"
"time"
)
var nonAlnum = regexp.MustCompile(`[^a-z0-9]+`)
var leadTrailHyphen = regexp.MustCompile(`^-+|-+$`)
// Slugify converts a title to a URL-friendly slug.
// Lowercase, non-alphanumeric replaced with hyphens, trimmed.
func Slugify(title string) string {
s := strings.ToLower(strings.TrimSpace(title))
s = nonAlnum.ReplaceAllString(s, "-")
s = leadTrailHyphen.ReplaceAllString(s, "")
return s
}
// AutoTitle generates a default title when none is provided: task-HHMMSS.
func AutoTitle() string {
now := time.Now()
return fmt.Sprintf("task-%s", now.Format("150405"))
}
// DirName produces the workspace directory name: YYYY-MM-DD_slug.
func DirName(date, slug string) string {
return date + "_" + slug
}
// ResolveDir returns the full path for a new workspace, appending -2, -3, etc.
// if a directory with the same date+slug already exists.
func ResolveDir(categoryDir, date, slug string) string {
base := DirName(date, slug)
candidate := filepath.Join(categoryDir, base)
if _, err := os.Stat(candidate); os.IsNotExist(err) {
return candidate
}
for i := 2; ; i++ {
suffixed := fmt.Sprintf("%s-%d", base, i)
candidate = filepath.Join(categoryDir, suffixed)
if _, err := os.Stat(candidate); os.IsNotExist(err) {
return candidate
}
}
}
+94
View File
@@ -0,0 +1,94 @@
package workspace
import (
"os"
"path/filepath"
"testing"
)
func TestSlugify(t *testing.T) {
tests := []struct {
input string
want string
}{
{"arch notes", "arch-notes"},
{"Arch Notes", "arch-notes"},
{" hello world ", "hello-world"},
{"foo---bar", "foo-bar"},
{"Test 123!", "test-123"},
{"---leading---", "leading"},
{"trailing---", "trailing"},
{"UPPERCASE", "uppercase"},
{"a", "a"},
{"", ""},
{"hello_world", "hello-world"},
{"foo@bar#baz", "foo-bar-baz"},
{" multiple spaces ", "multiple-spaces"},
}
for _, tt := range tests {
got := Slugify(tt.input)
if got != tt.want {
t.Errorf("Slugify(%q) = %q, want %q", tt.input, got, tt.want)
}
}
}
func TestAutoTitle(t *testing.T) {
title := AutoTitle()
if title == "" {
t.Error("AutoTitle() returned empty string")
}
if len(title) != 11 { // "task-" + 6 digits
t.Errorf("AutoTitle() = %q, expected 11 chars", title)
}
if title[:5] != "task-" {
t.Errorf("AutoTitle() = %q, expected prefix \"task-\"", title)
}
}
func TestDirName(t *testing.T) {
name := DirName("2026-04-05", "arch-notes")
if name != "2026-04-05_arch-notes" {
t.Errorf("DirName = %q, want \"2026-04-05_arch-notes\"", name)
}
}
func TestResolveDirNoCollision(t *testing.T) {
root := t.TempDir()
cat := filepath.Join(root, "general")
os.MkdirAll(cat, 0755)
got := ResolveDir(cat, "2026-04-05", "test")
if filepath.Base(got) != "2026-04-05_test" {
t.Errorf("no collision: got %q, want dir ending in 2026-04-05_test", got)
}
}
func TestResolveDirCollision(t *testing.T) {
root := t.TempDir()
cat := filepath.Join(root, "general")
os.MkdirAll(cat, 0755)
date := "2026-04-05"
slug := "test"
// First call: no collision
got := ResolveDir(cat, date, slug)
os.MkdirAll(got, 0755)
// Second call: should get -2
got2 := ResolveDir(cat, date, slug)
if filepath.Base(got2) != "2026-04-05_test-2" {
t.Errorf("second call: got %q, want dir ending in 2026-04-05_test-2", got2)
}
// Create that too
os.MkdirAll(got2, 0755)
// Third call: should get -3
got3 := ResolveDir(cat, date, slug)
if filepath.Base(got3) != "2026-04-05_test-3" {
t.Errorf("third call: got %q, want dir ending in 2026-04-05_test-3", got3)
}
}