diff --git a/internal/workspace/slug.go b/internal/workspace/slug.go new file mode 100644 index 0000000..d269514 --- /dev/null +++ b/internal/workspace/slug.go @@ -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 + } + } +} diff --git a/internal/workspace/slug_test.go b/internal/workspace/slug_test.go new file mode 100644 index 0000000..139f8d8 --- /dev/null +++ b/internal/workspace/slug_test.go @@ -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) + } +}