diff --git a/internal/session/sessionname.go b/internal/session/sessionname.go new file mode 100644 index 0000000..1ca74db --- /dev/null +++ b/internal/session/sessionname.go @@ -0,0 +1,68 @@ +package session + +import ( + "crypto/sha256" + "encoding/hex" + "path/filepath" + "runtime" + "strings" +) + +// SessionName returns a stable tmux session name for the given workspace. +// Format: ctask---. +// +// category and slug are sanitized to [A-Za-z0-9_-]; characters outside that +// set become '_'; runs of '_' collapse; both are lowercased. slug is +// truncated to 30 characters maximum (after sanitization). hash6 is the +// first six lowercase-hex characters of sha256(canonical absolute path). +// +// On Windows the path is lowercased before hashing to match +// config.searchRootKey conventions for case-insensitive filesystems. +func SessionName(category, slug, absWsPath string) string { + cat := sanitizeNameComponent(category) + sl := sanitizeNameComponent(slug) + if len(sl) > 30 { + sl = sl[:30] + sl = strings.TrimRight(sl, "_") + } + clean := filepath.Clean(absWsPath) + if runtime.GOOS == "windows" { + clean = strings.ToLower(clean) + } + sum := sha256.Sum256([]byte(clean)) + hash := hex.EncodeToString(sum[:])[:6] + return "ctask-" + cat + "-" + sl + "-" + hash +} + +// sanitizeNameComponent replaces every char outside [A-Za-z0-9_-] with '_', +// collapses runs of '_', trims leading/trailing '_' and '-', and lowercases. +func sanitizeNameComponent(s string) string { + if s == "" { + return "_" + } + var b strings.Builder + b.Grow(len(s)) + for _, r := range s { + switch { + case r >= 'a' && r <= 'z': + b.WriteRune(r) + case r >= 'A' && r <= 'Z': + b.WriteRune(r + ('a' - 'A')) + case r >= '0' && r <= '9': + b.WriteRune(r) + case r == '_' || r == '-': + b.WriteRune(r) + default: + b.WriteByte('_') + } + } + out := b.String() + for strings.Contains(out, "__") { + out = strings.ReplaceAll(out, "__", "_") + } + out = strings.Trim(out, "_-") + if out == "" { + return "_" + } + return out +} diff --git a/internal/session/sessionname_test.go b/internal/session/sessionname_test.go new file mode 100644 index 0000000..b25dc2d --- /dev/null +++ b/internal/session/sessionname_test.go @@ -0,0 +1,84 @@ +package session + +import ( + "runtime" + "strings" + "testing" +) + +func TestSessionNameStableAcrossRuns(t *testing.T) { + a := SessionName("projects", "promptvolley-v3", "/home/warren/ai-workspaces/projects/promptvolley-v3") + b := SessionName("projects", "promptvolley-v3", "/home/warren/ai-workspaces/projects/promptvolley-v3") + if a != b { + t.Errorf("not stable: %q vs %q", a, b) + } +} + +func TestSessionNamePrefixAndShape(t *testing.T) { + got := SessionName("projects", "promptvolley-v3", "/abs/path/promptvolley-v3") + if !strings.HasPrefix(got, "ctask-projects-promptvolley-v3-") { + t.Errorf("unexpected prefix: %q", got) + } + parts := strings.Split(got, "-") + hash := parts[len(parts)-1] + if len(hash) != 6 { + t.Errorf("expected 6-char hash suffix, got %q (full %q)", hash, got) + } + for _, c := range hash { + ok := (c >= '0' && c <= '9') || (c >= 'a' && c <= 'f') + if !ok { + t.Errorf("hash must be lowercase hex: %q", hash) + break + } + } +} + +func TestSessionNameSanitizesUnsafeCharacters(t *testing.T) { + got := SessionName("My Cat/Egory", "weird name with spaces & punct!", "/abs/path/x") + if strings.ContainsAny(got, " /&!?") { + t.Errorf("sanitization missed unsafe chars: %q", got) + } + if !strings.HasPrefix(got, "ctask-my_cat_egory-") { + t.Errorf("category not lowercased+sanitized: %q", got) + } +} + +func TestSessionNameSlugTruncatedAt30(t *testing.T) { + long := strings.Repeat("a", 50) + got := SessionName("projects", long, "/abs/path/x") + parts := strings.Split(got, "-") + if len(parts) < 4 { + t.Fatalf("unexpected shape: %q", got) + } + slugTokens := parts[2 : len(parts)-1] + slug := strings.Join(slugTokens, "-") + if len(slug) > 30 { + t.Errorf("slug not truncated to 30: got %d chars (%q)", len(slug), slug) + } +} + +func TestSessionNameDifferentPathsCollideOnSlugButNotOverall(t *testing.T) { + a := SessionName("projects", "demo", "/path/one/demo") + b := SessionName("projects", "demo", "/path/two/demo") + if a == b { + t.Errorf("hash should differ on path: both %q", a) + } +} + +func TestSessionNameWindowsPathCaseInsensitive(t *testing.T) { + if runtime.GOOS != "windows" { + t.Skip("Windows-only path canonicalization") + } + a := SessionName("projects", "demo", "C:\\Users\\Warren\\X") + b := SessionName("projects", "demo", "c:\\users\\warren\\x") + if a != b { + t.Errorf("Windows paths must be case-insensitive: %q vs %q", a, b) + } +} + +func TestSessionNameRunsOfUnderscoresCollapsed(t *testing.T) { + got := SessionName("a b", "x y", "/abs/x") + if strings.Contains(got, "__") { + t.Errorf("runs of _ must collapse: %q", got) + } +}