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:
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user