diff --git a/internal/session/session_dir_test.go b/internal/session/session_dir_test.go new file mode 100644 index 00000000..764bfceb --- /dev/null +++ b/internal/session/session_dir_test.go @@ -0,0 +1,70 @@ +package session + +import ( + "strings" + "testing" +) + +// TestEncodeCwdForDir verifies the working-directory → session-directory +// name encoding strips characters that are illegal on Windows (notably the +// drive-letter colon, see issue #18) while preserving the previous output +// for the typical Unix paths. +func TestEncodeCwdForDir(t *testing.T) { + tests := []struct { + name string + cwd string + want string + }{ + { + name: "unix absolute path", + cwd: "/home/user/proj", + want: "home--user--proj", + }, + { + name: "unix relative path", + cwd: "proj/sub", + want: "proj--sub", + }, + { + name: "windows drive root", + cwd: `C:\test`, + want: "C--test", + }, + { + name: "windows nested path", + cwd: `C:\Users\User\code`, + want: "C--Users--User--code", + }, + { + name: "windows secondary drive", + cwd: `S:\work\repo`, + want: "S--work--repo", + }, + { + name: "windows mixed separators", + cwd: `C:\Users/User\code`, + want: "C--Users--User--code", + }, + { + name: "windows other illegal chars stripped", + cwd: `C:\ac|d?e*f"g`, + want: "C--abcdefg", + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + got := encodeCwdForDir(tc.cwd) + if got != tc.want { + t.Errorf("encodeCwdForDir(%q) = %q, want %q", tc.cwd, got, tc.want) + } + // Encoded directory must never contain characters that are + // illegal in Windows directory names. + for _, bad := range []string{":", "<", ">", "\"", "|", "?", "*", "\\", "/"} { + if strings.Contains(got, bad) { + t.Errorf("encodeCwdForDir(%q) = %q contains illegal char %q", tc.cwd, got, bad) + } + } + }) + } +} diff --git a/internal/session/tree_manager.go b/internal/session/tree_manager.go index a8914e02..b3959846 100644 --- a/internal/session/tree_manager.go +++ b/internal/session/tree_manager.go @@ -1350,15 +1350,44 @@ func (tm *TreeManager) buildTreeNodeDepth(id string, depth int, visited map[stri // --- Path conventions --- // DefaultSessionDir returns the default session storage directory for a cwd. -// Convention: ~/.kit/sessions/----/ +// Convention: ~/.kit/sessions/, where path separators are +// encoded as "--" with no leading or trailing dashes — e.g. +// /home/user/proj becomes home--user--proj. See encodeCwdForDir for the +// full encoding rules (including Windows path handling). func DefaultSessionDir(cwd string) string { home, err := os.UserHomeDir() if err != nil { home = "." } - // Convert path separators to double dashes. - safeCwd := strings.ReplaceAll(cwd, string(filepath.Separator), "--") + return filepath.Join(home, ".kit", "sessions", encodeCwdForDir(cwd)) +} + +// encodeCwdForDir converts a working-directory path into a single, filesystem- +// safe directory name. Path separators are replaced with double dashes and +// characters that are illegal in Windows directory names — most importantly +// the colon that follows the drive letter (e.g. `C:\foo` → `C--foo`) — are +// stripped. The result is identical to the previous Unix-only encoding for +// paths that do not contain such characters, so existing session directories +// are preserved. +func encodeCwdForDir(cwd string) string { + // Convert both `/` and `\` to double dashes so encoding is stable across + // platforms and remains correct on Windows where `filepath.Separator` + // would otherwise miss forward-slash style paths. + safeCwd := strings.ReplaceAll(cwd, "\\", "--") + safeCwd = strings.ReplaceAll(safeCwd, "/", "--") // Remove leading separator replacement. safeCwd = strings.TrimPrefix(safeCwd, "--") - return filepath.Join(home, ".kit", "sessions", safeCwd) + // Strip characters that are illegal in directory names on Windows + // (`< > : " | ? *`). On Unix these characters are legal but rare in + // practice; stripping them keeps the encoding portable. + replacer := strings.NewReplacer( + ":", "", + "<", "", + ">", "", + "\"", "", + "|", "", + "?", "", + "*", "", + ) + return replacer.Replace(safeCwd) }