From 8b928f7a534c0d6a9886e75869d6cbf6fcca6665 Mon Sep 17 00:00:00 2001 From: ersan bilik Date: Sat, 7 Mar 2026 05:39:28 +0300 Subject: [PATCH] fix: Windows cross-platform compatibility Runtime fixes: - validation/path.go: case-insensitive boundary checks on Windows (VS Code lowercases drive letters, EvalSymlinks uppercases) - memory/discover.go: platform-independent project slugs with filepath.ToSlash and Windows drive prefix stripping - recall/parser/path.go: use forward slashes consistently for session path operations (not platform separator) - journal/core/generate.go: normalize source links and nav paths to forward slashes - site/cmd/feed/run.go: use config.NewlineLF and config.ExtMarkdown constants; use Println for cross-platform line endings Test fixes: - Set USERPROFILE alongside HOME in 50+ test functions across 10 files (os.UserHomeDir reads USERPROFILE on Windows) - Set APPDATA in recall tests (XDG fallback reads APPDATA on Windows) - recall/core/format_test.go: replace t.Setenv TZ with setLocalUTC helper (TZ env var does not affect time.Local on Windows) - cli_test.go: append .exe suffix on Windows for test binary - serve_test.go: use os.TempDir, .bat fake binaries, platform PATH separator - pad_test.go: skip file permission test on Windows; use filepath.Separator in path assertions - crypto_test.go: skip Unix permission check on Windows - journal/core/generate_test.go: use t.TempDir with filepath.ToSlash instead of hardcoded Unix paths Signed-off-by: ersan bilik --- internal/cli/cli_test.go | 7 ++- internal/cli/doctor/doctor_test.go | 5 ++- internal/cli/initialize/init_test.go | 3 ++ internal/cli/journal/core/generate.go | 11 ++--- internal/cli/journal/core/generate_test.go | 17 ++++++-- internal/cli/pad/pad_test.go | 11 ++++- internal/cli/recall/core/format_test.go | 22 +++++++--- internal/cli/recall/run_test.go | 50 ++++++++++++++++++++++ internal/cli/serve/serve_test.go | 30 +++++++++---- internal/cli/site/cmd/feed/run.go | 13 +++--- internal/config/keypath_test.go | 6 +++ internal/config/migrate_test.go | 4 ++ internal/crypto/crypto_test.go | 7 ++- internal/memory/discover.go | 8 +++- internal/memory/discover_test.go | 2 + internal/recall/parser/path.go | 6 +-- internal/validation/path.go | 16 ++++++- internal/validation/path_test.go | 44 +++++++++++++++++++ 18 files changed, 221 insertions(+), 41 deletions(-) diff --git a/internal/cli/cli_test.go b/internal/cli/cli_test.go index 26864b38..0a84b022 100644 --- a/internal/cli/cli_test.go +++ b/internal/cli/cli_test.go @@ -13,6 +13,7 @@ import ( "os" "os/exec" "path/filepath" + "runtime" "strings" "testing" ) @@ -34,7 +35,11 @@ func TestBinaryIntegration(t *testing.T) { defer func() { _ = os.RemoveAll(tmpDir) }() // Build the binary - binaryPath := filepath.Join(tmpDir, "ctx-test-binary") + binaryName := "ctx-test-binary" + if runtime.GOOS == "windows" { + binaryName += ".exe" + } + binaryPath := filepath.Join(tmpDir, binaryName) buildCmd := exec.Command("go", "build", "-o", binaryPath, "./cmd/ctx") //nolint:gosec // G204: test builds local binary buildCmd.Env = append(os.Environ(), "CGO_ENABLED=0") diff --git a/internal/cli/doctor/doctor_test.go b/internal/cli/doctor/doctor_test.go index cfeadadf..b3b84f64 100644 --- a/internal/cli/doctor/doctor_test.go +++ b/internal/cli/doctor/doctor_test.go @@ -216,7 +216,9 @@ func TestDoctor_PluginNotInstalled(t *testing.T) { setupContextDir(t) // Set HOME to a temp dir with no plugin files. - t.Setenv("HOME", t.TempDir()) + tmpHome0 := t.TempDir() + t.Setenv("HOME", tmpHome0) + t.Setenv("USERPROFILE", tmpHome0) cmd := Cmd() var out bytes.Buffer @@ -238,6 +240,7 @@ func TestDoctor_PluginInstalledNotEnabled(t *testing.T) { tmpHome := t.TempDir() t.Setenv("HOME", tmpHome) + t.Setenv("USERPROFILE", tmpHome) // Create installed_plugins.json with ctx plugin. pluginsDir := filepath.Join(tmpHome, ".claude", "plugins") diff --git a/internal/cli/initialize/init_test.go b/internal/cli/initialize/init_test.go index 2f69479d..624777e4 100644 --- a/internal/cli/initialize/init_test.go +++ b/internal/cli/initialize/init_test.go @@ -347,6 +347,7 @@ func TestRunInit_Minimal(t *testing.T) { } defer func() { _ = os.Chdir(origDir) }() t.Setenv("HOME", tmpDir) + t.Setenv("USERPROFILE", tmpDir) t.Setenv(config.EnvSkipPathCheck, config.EnvTrue) cmd := Cmd() @@ -381,6 +382,7 @@ func TestRunInit_Force(t *testing.T) { } defer func() { _ = os.Chdir(origDir) }() t.Setenv("HOME", tmpDir) + t.Setenv("USERPROFILE", tmpDir) t.Setenv(config.EnvSkipPathCheck, config.EnvTrue) cmd := Cmd() @@ -413,6 +415,7 @@ func TestRunInit_Merge(t *testing.T) { } defer func() { _ = os.Chdir(origDir) }() t.Setenv("HOME", tmpDir) + t.Setenv("USERPROFILE", tmpDir) t.Setenv(config.EnvSkipPathCheck, config.EnvTrue) if err = os.WriteFile(config.FileClaudeMd, []byte("# My Project\n\nExisting.\n"), 0600); err != nil { diff --git a/internal/cli/journal/core/generate.go b/internal/cli/journal/core/generate.go index b684c9f6..73b9d440 100644 --- a/internal/cli/journal/core/generate.go +++ b/internal/cli/journal/core/generate.go @@ -163,9 +163,10 @@ func InjectSourceLink(content, sourcePath string) string { if pathErr != nil { absPath = sourcePath } - relPath := filepath.Join( + absPath = filepath.ToSlash(absPath) + relPath := filepath.ToSlash(filepath.Join( config.DirContext, config.DirJournal, filepath.Base(absPath), - ) + )) link := fmt.Sprintf(config.TplJournalSourceLink+nl+nl, absPath, relPath, relPath) @@ -209,19 +210,19 @@ func GenerateZensicalToml( if len(topics) > 0 { sb.WriteString(fmt.Sprintf(config.TplJournalNavItem+nl, config.JournalLabelTopics, - filepath.Join(config.JournalDirTopics, config.FilenameIndex)), + filepath.ToSlash(filepath.Join(config.JournalDirTopics, config.FilenameIndex))), ) } if len(keyFiles) > 0 { sb.WriteString(fmt.Sprintf(config.TplJournalNavItem+nl, config.JournalLabelFiles, - filepath.Join(config.JournalDirFiles, config.FilenameIndex)), + filepath.ToSlash(filepath.Join(config.JournalDirFiles, config.FilenameIndex))), ) } if len(sessionTypes) > 0 { sb.WriteString(fmt.Sprintf(config.TplJournalNavItem+nl, config.JournalLabelTypes, - filepath.Join(config.JournalDirTypes, config.FilenameIndex)), + filepath.ToSlash(filepath.Join(config.JournalDirTypes, config.FilenameIndex))), ) } diff --git a/internal/cli/journal/core/generate_test.go b/internal/cli/journal/core/generate_test.go index 0661ef85..298bb1ba 100644 --- a/internal/cli/journal/core/generate_test.go +++ b/internal/cli/journal/core/generate_test.go @@ -7,6 +7,7 @@ package core import ( + "path/filepath" "strings" "testing" ) @@ -62,10 +63,14 @@ func TestGenerateIndex(t *testing.T) { } func TestInjectSourceLink_WithFrontmatter(t *testing.T) { + // Use a real temp path so filepath.Abs() is a no-op on all platforms. + srcPath := filepath.Join(t.TempDir(), ".context", "journal", "test.md") + wantAbs := filepath.ToSlash(srcPath) + content := "---\ntitle: Test\n---\n\n# Heading\n" - result := InjectSourceLink(content, "/home/user/.context/journal/test.md") + result := InjectSourceLink(content, srcPath) - if !strings.Contains(result, "[View source](file:///home/user/.context/journal/test.md)") { + if !strings.Contains(result, "[View source](file://"+wantAbs+")") { t.Errorf("missing file:// link:\n%s", result) } if !strings.Contains(result, ".context/journal/test.md") { @@ -77,10 +82,14 @@ func TestInjectSourceLink_WithFrontmatter(t *testing.T) { } func TestInjectSourceLink_NoFrontmatter(t *testing.T) { + // Use a real temp path so filepath.Abs() is a no-op on all platforms. + srcPath := filepath.Join(t.TempDir(), "file.md") + wantAbs := filepath.ToSlash(srcPath) + content := "# Heading\n\nSome text.\n" - result := InjectSourceLink(content, "/path/to/file.md") + result := InjectSourceLink(content, srcPath) - if !strings.HasPrefix(result, "*[View source](file:///path/to/file.md)") { + if !strings.HasPrefix(result, "*[View source](file://"+wantAbs+")") { t.Errorf("source link not at top:\n%s", result) } if !strings.Contains(result, ".context/journal/file.md") { diff --git a/internal/cli/pad/pad_test.go b/internal/cli/pad/pad_test.go index 36e201bf..8ad6f7fb 100644 --- a/internal/cli/pad/pad_test.go +++ b/internal/cli/pad/pad_test.go @@ -12,6 +12,7 @@ import ( "fmt" "os" "path/filepath" + "runtime" "strings" "testing" @@ -35,6 +36,7 @@ func setupEncrypted(t *testing.T) string { t.Fatal(err) } t.Setenv("HOME", dir) + t.Setenv("USERPROFILE", dir) t.Cleanup(func() { _ = os.Chdir(origDir) rc.Reset() @@ -74,6 +76,7 @@ func setupPlaintext(t *testing.T) string { t.Fatal(err) } t.Setenv("HOME", dir) + t.Setenv("USERPROFILE", dir) t.Cleanup(func() { _ = os.Chdir(origDir) rc.Reset() @@ -479,6 +482,7 @@ func TestMv_OutOfRange(t *testing.T) { func TestNoKey_EncryptedFileExists(t *testing.T) { dir := t.TempDir() t.Setenv("HOME", dir) + t.Setenv("USERPROFILE", dir) origDir, _ := os.Getwd() if err := os.Chdir(dir); err != nil { t.Fatal(err) @@ -802,7 +806,7 @@ func TestKeyPath(t *testing.T) { if !strings.HasSuffix(path, ".key") { t.Errorf("core.KeyPath() = %q, want suffix %q", path, ".key") } - if !strings.Contains(path, ".ctx/") { + if !strings.Contains(path, ".ctx"+string(filepath.Separator)) { t.Errorf("core.KeyPath() = %q, want global path containing .ctx/", path) } } @@ -824,6 +828,7 @@ func TestEnsureKey_EncFileExistsNoKey(t *testing.T) { t.Fatal(err) } t.Setenv("HOME", dir) + t.Setenv("USERPROFILE", dir) t.Cleanup(func() { _ = os.Chdir(origDir) rc.Reset() @@ -859,6 +864,7 @@ func TestEnsureKey_GeneratesNewKey(t *testing.T) { t.Fatal(err) } t.Setenv("HOME", dir) + t.Setenv("USERPROFILE", dir) t.Cleanup(func() { _ = os.Chdir(origDir) rc.Reset() @@ -2342,6 +2348,9 @@ func TestExport_Encrypted(t *testing.T) { } func TestExport_FilePermissions(t *testing.T) { + if runtime.GOOS == "windows" { + t.Skip("file permission bits not supported on Windows") + } dir := setupPlaintext(t) f := filepath.Join(dir, "file.txt") diff --git a/internal/cli/recall/core/format_test.go b/internal/cli/recall/core/format_test.go index 678eef55..42bc3156 100644 --- a/internal/cli/recall/core/format_test.go +++ b/internal/cli/recall/core/format_test.go @@ -14,6 +14,14 @@ import ( "github.com/ActiveMemory/ctx/internal/recall/parser" ) +// setLocalUTC forces time.Local to UTC for the duration of the test. +// On Windows, t.Setenv("TZ", "UTC") does not affect time.Local. +func setLocalUTC(t *testing.T) { + orig := time.Local + time.Local = time.UTC + t.Cleanup(func() { time.Local = orig }) +} + // stubDuration implements the interface{ Minutes() float64 } used by FormatDuration. type stubDuration struct{ mins float64 } @@ -342,7 +350,7 @@ func TestFormatPartNavigation(t *testing.T) { // --- FormatJournalEntryPart tests --- func TestFormatJournalEntryPart_SinglePart(t *testing.T) { - t.Setenv("TZ", "UTC") + setLocalUTC(t) s := &parser.Session{ ID: "abc12345-session-id", @@ -409,7 +417,7 @@ func TestFormatJournalEntryPart_SinglePart(t *testing.T) { } func TestFormatJournalEntryPart_MultiPart(t *testing.T) { - t.Setenv("TZ", "UTC") + setLocalUTC(t) s := &parser.Session{ ID: "multi-session-id-12345678", @@ -468,7 +476,7 @@ func TestFormatJournalEntryPart_MultiPart(t *testing.T) { } func TestFormatJournalEntryPart_WithToolUse(t *testing.T) { - t.Setenv("TZ", "UTC") + setLocalUTC(t) s := &parser.Session{ ID: "tool-session-id-1234", @@ -546,7 +554,7 @@ func TestFormatJournalEntryPart_WithToolUse(t *testing.T) { } func TestFormatJournalFilename_WithSlugOverride(t *testing.T) { - t.Setenv("TZ", "UTC") + setLocalUTC(t) s := &parser.Session{ ID: "abc12345-full-session-uuid", @@ -568,7 +576,7 @@ func TestFormatJournalFilename_WithSlugOverride(t *testing.T) { } func TestFormatJournalEntryPart_SessionIDInFrontmatter(t *testing.T) { - t.Setenv("TZ", "UTC") + setLocalUTC(t) s := &parser.Session{ ID: "abc12345-full-session-uuid", @@ -592,7 +600,7 @@ func TestFormatJournalEntryPart_SessionIDInFrontmatter(t *testing.T) { } func TestFormatJournalEntryPart_TitleInFrontmatterAndHeading(t *testing.T) { - t.Setenv("TZ", "UTC") + setLocalUTC(t) s := &parser.Session{ ID: "abc12345-full-session-uuid", @@ -625,7 +633,7 @@ func TestFormatJournalEntryPart_TitleInFrontmatterAndHeading(t *testing.T) { } func TestFormatJournalEntryPart_NoTitleUsesSlug(t *testing.T) { - t.Setenv("TZ", "UTC") + setLocalUTC(t) s := &parser.Session{ ID: "abc12345-full-session-uuid", diff --git a/internal/cli/recall/run_test.go b/internal/cli/recall/run_test.go index 9c3dff1b..3c0b95aa 100644 --- a/internal/cli/recall/run_test.go +++ b/internal/cli/recall/run_test.go @@ -79,6 +79,8 @@ func TestRunRecallExport_ArgValidation(t *testing.T) { func TestRunRecallList_NoSessions(t *testing.T) { tmpDir := t.TempDir() t.Setenv("HOME", tmpDir) + t.Setenv("USERPROFILE", tmpDir) + t.Setenv("APPDATA", tmpDir) // Create the expected directory structure (empty) claudeDir := filepath.Join(tmpDir, ".claude", "projects") @@ -105,6 +107,8 @@ func TestRunRecallList_NoSessions(t *testing.T) { func TestRunRecallList_WithSessions(t *testing.T) { tmpDir := t.TempDir() t.Setenv("HOME", tmpDir) + t.Setenv("USERPROFILE", tmpDir) + t.Setenv("APPDATA", tmpDir) // Create session fixture projDir := filepath.Join(tmpDir, ".claude", "projects", "-home-test-myproject") @@ -129,6 +133,8 @@ func TestRunRecallList_WithSessions(t *testing.T) { func TestRunRecallShow_Latest(t *testing.T) { tmpDir := t.TempDir() t.Setenv("HOME", tmpDir) + t.Setenv("USERPROFILE", tmpDir) + t.Setenv("APPDATA", tmpDir) projDir := filepath.Join(tmpDir, ".claude", "projects", "-home-test-showproj") createTestSessionJSONL(t, projDir, "sess-show-456", "show-test-session", "/home/test/showproj") @@ -156,6 +162,8 @@ func TestRunRecallShow_Latest(t *testing.T) { func TestRunRecallShow_BySlug(t *testing.T) { tmpDir := t.TempDir() t.Setenv("HOME", tmpDir) + t.Setenv("USERPROFILE", tmpDir) + t.Setenv("APPDATA", tmpDir) projDir := filepath.Join(tmpDir, ".claude", "projects", "-home-test-slugproj") createTestSessionJSONL(t, projDir, "sess-slug-789", "unique-slug-name", "/home/test/slugproj") @@ -179,6 +187,8 @@ func TestRunRecallShow_BySlug(t *testing.T) { func TestRunRecallExport_SingleSession(t *testing.T) { tmpDir := t.TempDir() t.Setenv("HOME", tmpDir) + t.Setenv("USERPROFILE", tmpDir) + t.Setenv("APPDATA", tmpDir) projDir := filepath.Join(tmpDir, ".claude", "projects", "-home-test-expproj") createTestSessionJSONL(t, projDir, "sess-exp-aaa", "export-session", "/home/test/expproj") @@ -250,6 +260,8 @@ func TestRunRecallExport_SingleSession(t *testing.T) { func TestRunRecallExport_DedupRenamesOldFile(t *testing.T) { tmpDir := t.TempDir() t.Setenv("HOME", tmpDir) + t.Setenv("USERPROFILE", tmpDir) + t.Setenv("APPDATA", tmpDir) projDir := filepath.Join(tmpDir, ".claude", "projects", "-home-test-dedupproj") sessionID := "dedup123-full-uuid-value" @@ -354,6 +366,8 @@ func exportHelper(t *testing.T, tmpDir string, extraArgs ...string) (journalDir func TestRunRecallExport_PreservesFrontmatter(t *testing.T) { tmpDir := t.TempDir() t.Setenv("HOME", tmpDir) + t.Setenv("USERPROFILE", tmpDir) + t.Setenv("APPDATA", tmpDir) projDir := filepath.Join(tmpDir, ".claude", "projects", "-home-test-fmproj") createTestSessionJSONL(t, projDir, "sess-fm-001", "fm-preserve", "/home/test/fmproj") @@ -407,6 +421,8 @@ func TestRunRecallExport_PreservesFrontmatter(t *testing.T) { func TestRunRecallExport_ForceDiscardsFrontmatter(t *testing.T) { tmpDir := t.TempDir() t.Setenv("HOME", tmpDir) + t.Setenv("USERPROFILE", tmpDir) + t.Setenv("APPDATA", tmpDir) projDir := filepath.Join(tmpDir, ".claude", "projects", "-home-test-forceproj") createTestSessionJSONL(t, projDir, "sess-force-002", "force-discard", "/home/test/forceproj") @@ -464,6 +480,8 @@ func TestRunRecallExport_ForceDiscardsFrontmatter(t *testing.T) { func TestRunRecallExport_ForceResetsEnrichmentState(t *testing.T) { tmpDir := t.TempDir() t.Setenv("HOME", tmpDir) + t.Setenv("USERPROFILE", tmpDir) + t.Setenv("APPDATA", tmpDir) projDir := filepath.Join(tmpDir, ".claude", "projects", "-home-test-stateproj") createTestSessionJSONL(t, projDir, "sess-state-003", "state-reset", "/home/test/stateproj") @@ -518,6 +536,8 @@ func TestRunRecallExport_ForceResetsEnrichmentState(t *testing.T) { func TestRunRecallExport_SkipExistingLeavesFile(t *testing.T) { tmpDir := t.TempDir() t.Setenv("HOME", tmpDir) + t.Setenv("USERPROFILE", tmpDir) + t.Setenv("APPDATA", tmpDir) projDir := filepath.Join(tmpDir, ".claude", "projects", "-home-test-skipproj") createTestSessionJSONL(t, projDir, "sess-skip-004", "skip-existing", "/home/test/skipproj") @@ -559,6 +579,8 @@ func TestRunRecallExport_SkipExistingLeavesFile(t *testing.T) { func TestRunRecallExport_AllSkipsExistingByDefault(t *testing.T) { tmpDir := t.TempDir() t.Setenv("HOME", tmpDir) + t.Setenv("USERPROFILE", tmpDir) + t.Setenv("APPDATA", tmpDir) projDir := filepath.Join(tmpDir, ".claude", "projects", "-home-test-safeskip") createTestSessionJSONL(t, projDir, "sess-safe-010", "safe-skip", "/home/test/safeskip") @@ -599,6 +621,8 @@ func TestRunRecallExport_AllSkipsExistingByDefault(t *testing.T) { func TestRunRecallExport_RegenerateReExports(t *testing.T) { tmpDir := t.TempDir() t.Setenv("HOME", tmpDir) + t.Setenv("USERPROFILE", tmpDir) + t.Setenv("APPDATA", tmpDir) projDir := filepath.Join(tmpDir, ".claude", "projects", "-home-test-regenproj") createTestSessionJSONL(t, projDir, "sess-regen-011", "regen-test", "/home/test/regenproj") @@ -654,6 +678,8 @@ func TestRunRecallExport_RegenerateRequiresAll(t *testing.T) { func TestRunRecallExport_DryRun(t *testing.T) { tmpDir := t.TempDir() t.Setenv("HOME", tmpDir) + t.Setenv("USERPROFILE", tmpDir) + t.Setenv("APPDATA", tmpDir) projDir := filepath.Join(tmpDir, ".claude", "projects", "-home-test-dryproj") createTestSessionJSONL(t, projDir, "sess-dry-012", "dry-run-test", "/home/test/dryproj") @@ -701,6 +727,8 @@ func TestRunRecallExport_DryRun(t *testing.T) { func TestRunRecallExport_DryRunRegenerate(t *testing.T) { tmpDir := t.TempDir() t.Setenv("HOME", tmpDir) + t.Setenv("USERPROFILE", tmpDir) + t.Setenv("APPDATA", tmpDir) projDir := filepath.Join(tmpDir, ".claude", "projects", "-home-test-dryregen") createTestSessionJSONL(t, projDir, "sess-dryregen-013", "dryregen-test", "/home/test/dryregen") @@ -761,6 +789,8 @@ func TestRunRecallExport_BareExportPrintsHelp(t *testing.T) { func TestRunRecallExport_SingleSessionAlwaysWrites(t *testing.T) { tmpDir := t.TempDir() t.Setenv("HOME", tmpDir) + t.Setenv("USERPROFILE", tmpDir) + t.Setenv("APPDATA", tmpDir) projDir := filepath.Join(tmpDir, ".claude", "projects", "-home-test-singleproj") createTestSessionJSONL(t, projDir, "sess-single-014", "single-write", "/home/test/singleproj") @@ -831,6 +861,8 @@ func TestRunRecallExport_SingleSessionAlwaysWrites(t *testing.T) { func TestRunRecallExport_YesBypasses(t *testing.T) { tmpDir := t.TempDir() t.Setenv("HOME", tmpDir) + t.Setenv("USERPROFILE", tmpDir) + t.Setenv("APPDATA", tmpDir) projDir := filepath.Join(tmpDir, ".claude", "projects", "-home-test-yesproj") createTestSessionJSONL(t, projDir, "sess-yes-015", "yes-bypass", "/home/test/yesproj") @@ -870,6 +902,8 @@ func TestRunRecallExport_YesBypasses(t *testing.T) { func TestRunRecallExport_LockedSkippedByDefault(t *testing.T) { tmpDir := t.TempDir() t.Setenv("HOME", tmpDir) + t.Setenv("USERPROFILE", tmpDir) + t.Setenv("APPDATA", tmpDir) projDir := filepath.Join(tmpDir, ".claude", "projects", "-home-test-lockskip") createTestSessionJSONL(t, projDir, "sess-lock-016", "lock-skip", "/home/test/lockskip") @@ -920,6 +954,8 @@ func TestRunRecallExport_LockedSkippedByDefault(t *testing.T) { func TestRunRecallExport_LockedSkippedByForce(t *testing.T) { tmpDir := t.TempDir() t.Setenv("HOME", tmpDir) + t.Setenv("USERPROFILE", tmpDir) + t.Setenv("APPDATA", tmpDir) projDir := filepath.Join(tmpDir, ".claude", "projects", "-home-test-lockforce") createTestSessionJSONL(t, projDir, "sess-lock-017", "lock-force", "/home/test/lockforce") @@ -970,6 +1006,8 @@ func TestRunRecallExport_LockedSkippedByForce(t *testing.T) { func TestRunRecallExport_KeepFrontmatterFalse(t *testing.T) { tmpDir := t.TempDir() t.Setenv("HOME", tmpDir) + t.Setenv("USERPROFILE", tmpDir) + t.Setenv("APPDATA", tmpDir) projDir := filepath.Join(tmpDir, ".claude", "projects", "-home-test-keepfm") createTestSessionJSONL(t, projDir, "sess-keepfm-018", "keepfm-test", "/home/test/keepfm") @@ -1032,6 +1070,8 @@ func TestRunRecallExport_KeepFrontmatterFalse(t *testing.T) { func TestRunRecallExport_KeepFrontmatterDefault(t *testing.T) { tmpDir := t.TempDir() t.Setenv("HOME", tmpDir) + t.Setenv("USERPROFILE", tmpDir) + t.Setenv("APPDATA", tmpDir) projDir := filepath.Join(tmpDir, ".claude", "projects", "-home-test-keepdef") createTestSessionJSONL(t, projDir, "sess-keepdef-019", "keepdef-test", "/home/test/keepdef") @@ -1084,6 +1124,8 @@ func TestRunRecallExport_KeepFrontmatterDefault(t *testing.T) { func TestRunRecallExport_DryRunShowsLocked(t *testing.T) { tmpDir := t.TempDir() t.Setenv("HOME", tmpDir) + t.Setenv("USERPROFILE", tmpDir) + t.Setenv("APPDATA", tmpDir) projDir := filepath.Join(tmpDir, ".claude", "projects", "-home-test-drylocked") createTestSessionJSONL(t, projDir, "sess-drylk-020", "drylk-test", "/home/test/drylocked") @@ -1171,6 +1213,8 @@ func TestDiscardFrontmatter(t *testing.T) { func TestRunRecallExport_FrontmatterLockedSkipsAndPromotesToState(t *testing.T) { tmpDir := t.TempDir() t.Setenv("HOME", tmpDir) + t.Setenv("USERPROFILE", tmpDir) + t.Setenv("APPDATA", tmpDir) projDir := filepath.Join(tmpDir, ".claude", "projects", "-home-test-fmlock") createTestSessionJSONL(t, projDir, "sess-fmlock-022", "fmlock-test", "/home/test/fmlock") @@ -1236,6 +1280,8 @@ func TestRunRecallExport_FrontmatterLockedSkipsAndPromotesToState(t *testing.T) func TestRunRecallExport_KeepFrontmatterFalseImpliesRegenerate(t *testing.T) { tmpDir := t.TempDir() t.Setenv("HOME", tmpDir) + t.Setenv("USERPROFILE", tmpDir) + t.Setenv("APPDATA", tmpDir) projDir := filepath.Join(tmpDir, ".claude", "projects", "-home-test-implyregen") createTestSessionJSONL(t, projDir, "sess-implyregen-021", "implyregen-test", "/home/test/implyregen") @@ -1276,6 +1322,8 @@ func TestRunRecallExport_KeepFrontmatterFalseImpliesRegenerate(t *testing.T) { func TestRunRecallExport_MalformedFrontmatterGracefulDegradation(t *testing.T) { tmpDir := t.TempDir() t.Setenv("HOME", tmpDir) + t.Setenv("USERPROFILE", tmpDir) + t.Setenv("APPDATA", tmpDir) projDir := filepath.Join(tmpDir, ".claude", "projects", "-home-test-malformed") createTestSessionJSONL(t, projDir, "sess-malformed-030", "malformed-fm", "/home/test/malformed") @@ -1352,6 +1400,8 @@ func createLargeTestSessionJSONL(t *testing.T, dir, sessionID, slug, cwd string, func TestRunRecallExport_MultipartFrontmatterPreservation(t *testing.T) { tmpDir := t.TempDir() t.Setenv("HOME", tmpDir) + t.Setenv("USERPROFILE", tmpDir) + t.Setenv("APPDATA", tmpDir) projDir := filepath.Join(tmpDir, ".claude", "projects", "-home-test-multipart") // 110 pairs = 220 messages, exceeding config.MaxMessagesPerPart (200) → 2 parts. diff --git a/internal/cli/serve/serve_test.go b/internal/cli/serve/serve_test.go index 90e306e5..af294b9b 100644 --- a/internal/cli/serve/serve_test.go +++ b/internal/cli/serve/serve_test.go @@ -9,6 +9,7 @@ package serve import ( "os" "path/filepath" + "runtime" "strings" "testing" @@ -58,7 +59,7 @@ func TestCmd_AcceptsArgs(t *testing.T) { } func TestRunServe_DirNotFound(t *testing.T) { - err := serveroot.Run([]string{"/tmp/nonexistent-dir-ctx-test-xyz"}) + err := serveroot.Run([]string{filepath.Join(os.TempDir(), "nonexistent-dir-ctx-test-xyz")}) if err == nil { t.Fatal("expected error for nonexistent directory") } @@ -196,17 +197,28 @@ func TestRunServe_WithMockZensical(t *testing.T) { if err := os.MkdirAll(binDir, 0750); err != nil { t.Fatalf("failed to create bin dir: %v", err) } - fakeZensical := filepath.Join(binDir, "zensical") - if err := os.WriteFile(fakeZensical, []byte("#!/bin/sh\nexit 0\n"), 0600); err != nil { - t.Fatalf("failed to create fake zensical: %v", err) - } - if err := os.Chmod(fakeZensical, 0755); err != nil { //nolint:gosec // test needs executable - t.Fatalf("failed to chmod fake zensical: %v", err) + if runtime.GOOS == "windows" { + fakeZensical := filepath.Join(binDir, "zensical.bat") + if err := os.WriteFile(fakeZensical, []byte("@exit /b 0\n"), 0600); err != nil { + t.Fatalf("failed to create fake zensical: %v", err) + } + } else { + fakeZensical := filepath.Join(binDir, "zensical") + if err := os.WriteFile(fakeZensical, []byte("#!/bin/sh\nexit 0\n"), 0600); err != nil { + t.Fatalf("failed to create fake zensical: %v", err) + } + if err := os.Chmod(fakeZensical, 0755); err != nil { //nolint:gosec // test needs executable + t.Fatalf("failed to chmod fake zensical: %v", err) + } } // Set PATH to include our fake binary origPath := os.Getenv("PATH") - t.Setenv("PATH", binDir+":"+origPath) + pathSep := ":" + if runtime.GOOS == "windows" { + pathSep = ";" + } + t.Setenv("PATH", binDir+pathSep+origPath) serveErr := serveroot.Run([]string{tmpDir}) if serveErr != nil { @@ -218,7 +230,7 @@ func TestCmd_RunE(t *testing.T) { // Test that Cmd().RunE actually invokes serveroot.Run via the command cmd := Cmd() // Set args to a nonexistent dir so we get a predictable error - cmd.SetArgs([]string{"/tmp/nonexistent-ctx-test-xyz"}) + cmd.SetArgs([]string{filepath.Join(os.TempDir(), "nonexistent-ctx-test-xyz")}) cmd.SilenceUsage = true cmd.SilenceErrors = true diff --git a/internal/cli/site/cmd/feed/run.go b/internal/cli/site/cmd/feed/run.go index bb04b7f8..db42b347 100644 --- a/internal/cli/site/cmd/feed/run.go +++ b/internal/cli/site/cmd/feed/run.go @@ -26,7 +26,7 @@ const ( atomNS = "http://www.w3.org/2005/Atom" feedTitle = "ctx blog" defaultAuthor = "Context contributors" - xmlHeader = `` + "\n" + xmlHeader = `` + config.NewlineLF ) // blogDatePattern matches filenames like 2026-02-25-slug.md. @@ -349,7 +349,7 @@ func generateAtom( } for _, p := range posts { - slug := strings.TrimSuffix(p.filename, ".md") + slug := strings.TrimSuffix(p.filename, config.ExtMarkdown) entryURL := blogURL + slug + "/" entry := core.AtomEntry{ @@ -415,20 +415,21 @@ func generateAtom( func printReport( cmd *cobra.Command, outPath string, report feedReport, ) { - cmd.Printf("\nGenerated %s (%d entries)\n", - outPath, report.included) + cmd.Println(fmt.Sprintf( + "\nGenerated %s (%d entries)", + outPath, report.included)) if len(report.skipped) > 0 { cmd.Println("\nSkipped:") for _, msg := range report.skipped { - cmd.Printf(" %s\n", msg) + cmd.Println(fmt.Sprintf(" %s", msg)) } } if len(report.warnings) > 0 { cmd.Println("\nWarnings:") for _, msg := range report.warnings { - cmd.Printf(" %s\n", msg) + cmd.Println(fmt.Sprintf(" %s", msg)) } } } diff --git a/internal/config/keypath_test.go b/internal/config/keypath_test.go index a7d2d64b..1c95855e 100644 --- a/internal/config/keypath_test.go +++ b/internal/config/keypath_test.go @@ -15,6 +15,7 @@ import ( func TestGlobalKeyPath(t *testing.T) { dir := t.TempDir() t.Setenv("HOME", dir) + t.Setenv("USERPROFILE", dir) got := GlobalKeyPath() want := filepath.Join(dir, ".ctx", FileContextKey) @@ -26,6 +27,7 @@ func TestGlobalKeyPath(t *testing.T) { func TestExpandHome_Tilde(t *testing.T) { dir := t.TempDir() t.Setenv("HOME", dir) + t.Setenv("USERPROFILE", dir) got := ExpandHome("~/foo") want := filepath.Join(dir, "foo") @@ -51,6 +53,7 @@ func TestExpandHome_TildeOnly(t *testing.T) { func TestResolveKeyPath_OverrideTakesPrecedence(t *testing.T) { dir := t.TempDir() t.Setenv("HOME", dir) + t.Setenv("USERPROFILE", dir) got := ResolveKeyPath(".context", "~/custom/my.key") want := filepath.Join(dir, "custom", "my.key") @@ -62,6 +65,7 @@ func TestResolveKeyPath_OverrideTakesPrecedence(t *testing.T) { func TestResolveKeyPath_ProjectLocalBeforeGlobal(t *testing.T) { dir := t.TempDir() t.Setenv("HOME", dir) + t.Setenv("USERPROFILE", dir) // Create both project-local and global keys. contextDir := filepath.Join(dir, "project", ".context") @@ -91,6 +95,7 @@ func TestResolveKeyPath_ProjectLocalBeforeGlobal(t *testing.T) { func TestResolveKeyPath_FallbackToGlobal(t *testing.T) { dir := t.TempDir() t.Setenv("HOME", dir) + t.Setenv("USERPROFILE", dir) // Create global key only — no project-local. globalDir := filepath.Join(dir, ".ctx") @@ -112,6 +117,7 @@ func TestResolveKeyPath_FallbackToGlobal(t *testing.T) { func TestResolveKeyPath_DefaultsToGlobal(t *testing.T) { dir := t.TempDir() t.Setenv("HOME", dir) + t.Setenv("USERPROFILE", dir) contextDir := filepath.Join(dir, "project", ".context") diff --git a/internal/config/migrate_test.go b/internal/config/migrate_test.go index 999b054c..642b259b 100644 --- a/internal/config/migrate_test.go +++ b/internal/config/migrate_test.go @@ -15,6 +15,7 @@ import ( func TestMigrateKeyFile_GlobalExists_Noop(t *testing.T) { dir := t.TempDir() t.Setenv("HOME", dir) + t.Setenv("USERPROFILE", dir) // Create global key. globalDir := filepath.Join(dir, ".ctx") @@ -47,6 +48,7 @@ func TestMigrateKeyFile_GlobalExists_Noop(t *testing.T) { func TestMigrateKeyFile_LegacyLocal_WarnsOnly(t *testing.T) { dir := t.TempDir() t.Setenv("HOME", dir) + t.Setenv("USERPROFILE", dir) contextDir := filepath.Join(dir, ".context") if err := os.MkdirAll(contextDir, 0750); err != nil { @@ -77,6 +79,7 @@ func TestMigrateKeyFile_LegacyLocal_WarnsOnly(t *testing.T) { func TestMigrateKeyFile_LegacyUserLevel_WarnsOnly(t *testing.T) { dir := t.TempDir() t.Setenv("HOME", dir) + t.Setenv("USERPROFILE", dir) // Create a legacy user-level key at ~/.local/ctx/keys/. legacyKeyDir := filepath.Join(dir, ".local", "ctx", "keys") @@ -111,6 +114,7 @@ func TestMigrateKeyFile_LegacyUserLevel_WarnsOnly(t *testing.T) { func TestMigrateKeyFile_NothingToDo(t *testing.T) { dir := t.TempDir() t.Setenv("HOME", dir) + t.Setenv("USERPROFILE", dir) contextDir := filepath.Join(dir, ".context") if err := os.MkdirAll(contextDir, 0750); err != nil { diff --git a/internal/crypto/crypto_test.go b/internal/crypto/crypto_test.go index 89b8c617..b9584297 100644 --- a/internal/crypto/crypto_test.go +++ b/internal/crypto/crypto_test.go @@ -10,6 +10,7 @@ import ( "bytes" "os" "path/filepath" + "runtime" "testing" ) @@ -145,8 +146,10 @@ func TestSaveKey_LoadKey_RoundTrip(t *testing.T) { if err != nil { t.Fatalf("Stat() error: %v", err) } - if perm := info.Mode().Perm(); perm != 0600 { - t.Errorf("key file permissions = %o, want 0600", perm) + if runtime.GOOS != "windows" { + if perm := info.Mode().Perm(); perm != 0600 { + t.Errorf("key file permissions = %o, want 0600", perm) + } } loaded, err := LoadKey(path) diff --git a/internal/memory/discover.go b/internal/memory/discover.go index 83134c7c..330c6c36 100644 --- a/internal/memory/discover.go +++ b/internal/memory/discover.go @@ -45,6 +45,12 @@ func DiscoverMemoryPath(projectRoot string) (string, error) { // // Example: /home/jose/WORKSPACE/ctx → -home-jose-WORKSPACE-ctx func ProjectSlug(absPath string) string { + // Normalise to forward-slash so the slug is platform-independent. + normalized := filepath.ToSlash(absPath) + // On Windows, strip the "X:" drive prefix. + if len(normalized) >= 2 && normalized[1] == ':' { + normalized = normalized[2:] + } // Strip leading "/" then replace remaining "/" with "-", prefix with "-" - return "-" + strings.ReplaceAll(absPath[1:], "/", "-") + return "-" + strings.ReplaceAll(normalized[1:], "/", "-") } diff --git a/internal/memory/discover_test.go b/internal/memory/discover_test.go index 589f6ac7..1e505b94 100644 --- a/internal/memory/discover_test.go +++ b/internal/memory/discover_test.go @@ -48,6 +48,7 @@ func TestProjectSlug(t *testing.T) { func TestDiscoverMemoryPath_Found(t *testing.T) { home := t.TempDir() t.Setenv("HOME", home) + t.Setenv("USERPROFILE", home) projectRoot := filepath.Join(home, "WORKSPACE", "myproject") slug := ProjectSlug(projectRoot) @@ -73,6 +74,7 @@ func TestDiscoverMemoryPath_Found(t *testing.T) { func TestDiscoverMemoryPath_NotFound(t *testing.T) { home := t.TempDir() t.Setenv("HOME", home) + t.Setenv("USERPROFILE", home) projectRoot := filepath.Join(home, "WORKSPACE", "nonexistent") _, discoverErr := DiscoverMemoryPath(projectRoot) diff --git a/internal/recall/parser/path.go b/internal/recall/parser/path.go index 52b6e031..c8ba2cf8 100644 --- a/internal/recall/parser/path.go +++ b/internal/recall/parser/path.go @@ -7,7 +7,6 @@ package parser import ( - "path/filepath" "strings" ) @@ -21,13 +20,14 @@ func getPathRelativeToHome(path string) string { // Handle common home directory patterns // /home/username/... -> strip /home/username // /Users/username/... -> strip /Users/username (macOS) - parts := strings.Split(path, string(filepath.Separator)) + // Always split on "/" because input paths originate from Unix-based systems. + parts := strings.Split(path, "/") for i, part := range parts { if part == "home" || part == "Users" { // Next part is username, rest is relative path if i+2 < len(parts) { - return filepath.Join(parts[i+2:]...) + return strings.Join(parts[i+2:], "/") } return "" } diff --git a/internal/validation/path.go b/internal/validation/path.go index e54ebc2c..c2b22e03 100644 --- a/internal/validation/path.go +++ b/internal/validation/path.go @@ -10,6 +10,7 @@ import ( "fmt" "os" "path/filepath" + "runtime" "strings" ) @@ -41,10 +42,23 @@ func ValidateBoundary(dir string) error { resolvedDir = filepath.Clean(absDir) } + // On Windows, path comparisons must be case-insensitive because + // filepath.EvalSymlinks resolves to actual disk casing while + // os.Getwd preserves the casing from the caller (e.g. VS Code + // passes a lowercase drive letter via fsPath). + equal := func(a, b string) bool { return a == b } + hasPrefix := strings.HasPrefix + if runtime.GOOS == "windows" { + equal = strings.EqualFold + hasPrefix = func(s, prefix string) bool { + return len(s) >= len(prefix) && strings.EqualFold(s[:len(prefix)], prefix) + } + } + // Ensure the resolved dir is equal to or nested under the project root. // Append os.PathSeparator to avoid "/foo/bar" matching "/foo/b". root := resolvedCwd + string(os.PathSeparator) - if resolvedDir != resolvedCwd && !strings.HasPrefix(resolvedDir, root) { + if !equal(resolvedDir, resolvedCwd) && !hasPrefix(resolvedDir, root) { return fmt.Errorf("context directory %q resolves outside project root %q", dir, resolvedCwd) } diff --git a/internal/validation/path_test.go b/internal/validation/path_test.go index 63d6add9..6e0071ca 100644 --- a/internal/validation/path_test.go +++ b/internal/validation/path_test.go @@ -9,6 +9,8 @@ package validation import ( "os" "path/filepath" + "runtime" + "strings" "testing" ) @@ -95,3 +97,45 @@ func TestCheckSymlinks(t *testing.T) { } }) } + +func TestValidateBoundary_WindowsCaseInsensitive(t *testing.T) { + if runtime.GOOS != "windows" { + t.Skip("Windows-only test") + } + + // Simulate the VS Code plugin scenario: CWD has a lowercase drive letter + // but EvalSymlinks resolves to the actual (uppercase) casing. + // When .context doesn't exist yet (first init), the fallback path + // preserves the lowercase letter, causing a case mismatch. + cwd, err := os.Getwd() + if err != nil { + t.Fatal(err) + } + + // Swap the drive letter case to simulate VS Code's fsPath + if len(cwd) >= 2 && cwd[1] == ':' { + var swapped string + if cwd[0] >= 'A' && cwd[0] <= 'Z' { + swapped = strings.ToLower(cwd[:1]) + cwd[1:] + } else { + swapped = strings.ToUpper(cwd[:1]) + cwd[1:] + } + + origDir, _ := os.Getwd() + if chErr := os.Chdir(swapped); chErr != nil { + t.Fatalf("cannot chdir to %s: %v", swapped, chErr) + } + defer func() { _ = os.Chdir(origDir) }() + + // Non-existent subdir simulates .context before init + nonExistent := filepath.Join(swapped, ".nonexistent-ctx-dir") + if err := ValidateBoundary(nonExistent); err != nil { + t.Errorf("ValidateBoundary(%q) with swapped drive case should pass, got: %v", nonExistent, err) + } + + // Also test the default relative path that ctx init uses + if err := ValidateBoundary(".context"); err != nil { + t.Errorf("ValidateBoundary(.context) with swapped drive case should pass, got: %v", err) + } + } +}