Skip to content

Commit c93d0c1

Browse files
committed
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 <ersanbilik@gmail.com>
1 parent 75ebb01 commit c93d0c1

16 files changed

Lines changed: 210 additions & 35 deletions

File tree

internal/cli/cli_test.go

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@ import (
1313
"os"
1414
"os/exec"
1515
"path/filepath"
16+
"runtime"
1617
"strings"
1718
"testing"
1819
)
@@ -34,7 +35,11 @@ func TestBinaryIntegration(t *testing.T) {
3435
defer func() { _ = os.RemoveAll(tmpDir) }()
3536

3637
// Build the binary
37-
binaryPath := filepath.Join(tmpDir, "ctx-test-binary")
38+
binaryName := "ctx-test-binary"
39+
if runtime.GOOS == "windows" {
40+
binaryName += ".exe"
41+
}
42+
binaryPath := filepath.Join(tmpDir, binaryName)
3843
buildCmd := exec.Command("go", "build", "-o", binaryPath, "./cmd/ctx") //nolint:gosec // G204: test builds local binary
3944
buildCmd.Env = append(os.Environ(), "CGO_ENABLED=0")
4045

internal/cli/doctor/doctor_test.go

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -218,7 +218,9 @@ func TestDoctor_PluginNotInstalled(t *testing.T) {
218218
setupContextDir(t)
219219

220220
// Set HOME to a temp dir with no plugin files.
221-
t.Setenv("HOME", t.TempDir())
221+
tmpHome0 := t.TempDir()
222+
t.Setenv("HOME", tmpHome0)
223+
t.Setenv("USERPROFILE", tmpHome0)
222224

223225
cmd := Cmd()
224226
var out bytes.Buffer
@@ -240,6 +242,7 @@ func TestDoctor_PluginInstalledNotEnabled(t *testing.T) {
240242

241243
tmpHome := t.TempDir()
242244
t.Setenv("HOME", tmpHome)
245+
t.Setenv("USERPROFILE", tmpHome)
243246

244247
// Create installed_plugins.json with ctx plugin.
245248
pluginsDir := filepath.Join(tmpHome, ".claude", "plugins")

internal/cli/initialize/init_test.go

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -349,6 +349,7 @@ func TestRunInit_Minimal(t *testing.T) {
349349
}
350350
defer func() { _ = os.Chdir(origDir) }()
351351
t.Setenv("HOME", tmpDir)
352+
t.Setenv("USERPROFILE", tmpDir)
352353
t.Setenv(env.SkipPathCheck, env.True)
353354

354355
cmd := Cmd()
@@ -383,6 +384,7 @@ func TestRunInit_Force(t *testing.T) {
383384
}
384385
defer func() { _ = os.Chdir(origDir) }()
385386
t.Setenv("HOME", tmpDir)
387+
t.Setenv("USERPROFILE", tmpDir)
386388
t.Setenv(env.SkipPathCheck, env.True)
387389

388390
cmd := Cmd()
@@ -415,6 +417,7 @@ func TestRunInit_Merge(t *testing.T) {
415417
}
416418
defer func() { _ = os.Chdir(origDir) }()
417419
t.Setenv("HOME", tmpDir)
420+
t.Setenv("USERPROFILE", tmpDir)
418421
t.Setenv(env.SkipPathCheck, env.True)
419422

420423
if err = os.WriteFile(claude2.Md, []byte("# My Project\n\nExisting.\n"), 0600); err != nil {

internal/cli/journal/core/generate.go

Lines changed: 6 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -168,9 +168,10 @@ func InjectSourceLink(content, sourcePath string) string {
168168
if pathErr != nil {
169169
absPath = sourcePath
170170
}
171-
relPath := filepath.Join(
171+
absPath = filepath.ToSlash(absPath)
172+
relPath := filepath.ToSlash(filepath.Join(
172173
dir.Context, dir.Journal, filepath.Base(absPath),
173-
)
174+
))
174175
link := fmt.Sprintf(assets.TplJournalSourceLink+nl+nl,
175176
absPath, relPath, relPath)
176177

@@ -214,19 +215,19 @@ func GenerateZensicalToml(
214215
if len(topics) > 0 {
215216
sb.WriteString(fmt.Sprintf(assets.TplJournalNavItem+nl,
216217
assets.JournalLabelTopics,
217-
filepath.Join(dir.JournTopics, file.Index)),
218+
filepath.ToSlash(filepath.Join(dir.JournTopics, file.Index))),
218219
)
219220
}
220221
if len(keyFiles) > 0 {
221222
sb.WriteString(fmt.Sprintf(assets.TplJournalNavItem+nl,
222223
assets.JournalLabelFiles,
223-
filepath.Join(dir.JournalFiles, file.Index)),
224+
filepath.ToSlash(filepath.Join(dir.JournalFiles, file.Index))),
224225
)
225226
}
226227
if len(sessionTypes) > 0 {
227228
sb.WriteString(fmt.Sprintf(assets.TplJournalNavItem+nl,
228229
assets.JournalLabelTypes,
229-
filepath.Join(dir.JournalTypes, file.Index)),
230+
filepath.ToSlash(filepath.Join(dir.JournalTypes, file.Index))),
230231
)
231232
}
232233

internal/cli/journal/core/generate_test.go

Lines changed: 13 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@
77
package core
88

99
import (
10+
"path/filepath"
1011
"strings"
1112
"testing"
1213
)
@@ -62,10 +63,14 @@ func TestGenerateIndex(t *testing.T) {
6263
}
6364

6465
func TestInjectSourceLink_WithFrontmatter(t *testing.T) {
66+
// Use a real temp path so filepath.Abs() is a no-op on all platforms.
67+
srcPath := filepath.Join(t.TempDir(), ".context", "journal", "test.md")
68+
wantAbs := filepath.ToSlash(srcPath)
69+
6570
content := "---\ntitle: Test\n---\n\n# Heading\n"
66-
result := InjectSourceLink(content, "/home/user/.context/journal/test.md")
71+
result := InjectSourceLink(content, srcPath)
6772

68-
if !strings.Contains(result, "[View source](file:///home/user/.context/journal/test.md)") {
73+
if !strings.Contains(result, "[View source](file://"+wantAbs+")") {
6974
t.Errorf("missing file:// link:\n%s", result)
7075
}
7176
if !strings.Contains(result, ".context/journal/test.md") {
@@ -77,10 +82,14 @@ func TestInjectSourceLink_WithFrontmatter(t *testing.T) {
7782
}
7883

7984
func TestInjectSourceLink_NoFrontmatter(t *testing.T) {
85+
// Use a real temp path so filepath.Abs() is a no-op on all platforms.
86+
srcPath := filepath.Join(t.TempDir(), "file.md")
87+
wantAbs := filepath.ToSlash(srcPath)
88+
8089
content := "# Heading\n\nSome text.\n"
81-
result := InjectSourceLink(content, "/path/to/file.md")
90+
result := InjectSourceLink(content, srcPath)
8291

83-
if !strings.HasPrefix(result, "*[View source](file:///path/to/file.md)") {
92+
if !strings.HasPrefix(result, "*[View source](file://"+wantAbs+")") {
8493
t.Errorf("source link not at top:\n%s", result)
8594
}
8695
if !strings.Contains(result, ".context/journal/file.md") {

internal/cli/pad/pad_test.go

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ import (
1212
"fmt"
1313
"os"
1414
"path/filepath"
15+
"runtime"
1516
"strings"
1617
"testing"
1718

@@ -37,6 +38,7 @@ func setupEncrypted(t *testing.T) string {
3738
t.Fatal(err)
3839
}
3940
t.Setenv("HOME", tmpDir)
41+
t.Setenv("USERPROFILE", tmpDir)
4042
t.Cleanup(func() {
4143
_ = os.Chdir(origDir)
4244
rc.Reset()
@@ -76,6 +78,7 @@ func setupPlaintext(t *testing.T) string {
7678
t.Fatal(err)
7779
}
7880
t.Setenv("HOME", tmpDir)
81+
t.Setenv("USERPROFILE", tmpDir)
7982
t.Cleanup(func() {
8083
_ = os.Chdir(origDir)
8184
rc.Reset()
@@ -481,6 +484,7 @@ func TestMv_OutOfRange(t *testing.T) {
481484
func TestNoKey_EncryptedFileExists(t *testing.T) {
482485
tmpDir := t.TempDir()
483486
t.Setenv("HOME", tmpDir)
487+
t.Setenv("USERPROFILE", tmpDir)
484488
origDir, _ := os.Getwd()
485489
if err := os.Chdir(tmpDir); err != nil {
486490
t.Fatal(err)
@@ -804,7 +808,7 @@ func TestKeyPath(t *testing.T) {
804808
if !strings.HasSuffix(path, ".key") {
805809
t.Errorf("core.KeyPath() = %q, want suffix %q", path, ".key")
806810
}
807-
if !strings.Contains(path, ".ctx/") {
811+
if !strings.Contains(path, ".ctx"+string(filepath.Separator)) {
808812
t.Errorf("core.KeyPath() = %q, want global path containing .ctx/", path)
809813
}
810814
}
@@ -826,6 +830,7 @@ func TestEnsureKey_EncFileExistsNoKey(t *testing.T) {
826830
t.Fatal(err)
827831
}
828832
t.Setenv("HOME", tmpDir)
833+
t.Setenv("USERPROFILE", tmpDir)
829834
t.Cleanup(func() {
830835
_ = os.Chdir(origDir)
831836
rc.Reset()
@@ -861,6 +866,7 @@ func TestEnsureKey_GeneratesNewKey(t *testing.T) {
861866
t.Fatal(err)
862867
}
863868
t.Setenv("HOME", tmpDir)
869+
t.Setenv("USERPROFILE", tmpDir)
864870
t.Cleanup(func() {
865871
_ = os.Chdir(origDir)
866872
rc.Reset()
@@ -2344,6 +2350,9 @@ func TestExport_Encrypted(t *testing.T) {
23442350
}
23452351

23462352
func TestExport_FilePermissions(t *testing.T) {
2353+
if runtime.GOOS == "windows" {
2354+
t.Skip("file permission bits not supported on Windows")
2355+
}
23472356
tmpDir := setupPlaintext(t)
23482357

23492358
f := filepath.Join(tmpDir, "file.txt")

internal/cli/recall/core/format_test.go

Lines changed: 15 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,14 @@ import (
1414
"github.com/ActiveMemory/ctx/internal/recall/parser"
1515
)
1616

17+
// setLocalUTC forces time.Local to UTC for the duration of the test.
18+
// On Windows, t.Setenv("TZ", "UTC") does not affect time.Local.
19+
func setLocalUTC(t *testing.T) {
20+
orig := time.Local
21+
time.Local = time.UTC
22+
t.Cleanup(func() { time.Local = orig })
23+
}
24+
1725
// stubDuration implements the interface{ Minutes() float64 } used by FormatDuration.
1826
type stubDuration struct{ mins float64 }
1927

@@ -342,7 +350,7 @@ func TestFormatPartNavigation(t *testing.T) {
342350
// --- FormatJournalEntryPart tests ---
343351

344352
func TestFormatJournalEntryPart_SinglePart(t *testing.T) {
345-
t.Setenv("TZ", "UTC")
353+
setLocalUTC(t)
346354

347355
s := &parser.Session{
348356
ID: "abc12345-session-id",
@@ -409,7 +417,7 @@ func TestFormatJournalEntryPart_SinglePart(t *testing.T) {
409417
}
410418

411419
func TestFormatJournalEntryPart_MultiPart(t *testing.T) {
412-
t.Setenv("TZ", "UTC")
420+
setLocalUTC(t)
413421

414422
s := &parser.Session{
415423
ID: "multi-session-id-12345678",
@@ -468,7 +476,7 @@ func TestFormatJournalEntryPart_MultiPart(t *testing.T) {
468476
}
469477

470478
func TestFormatJournalEntryPart_WithToolUse(t *testing.T) {
471-
t.Setenv("TZ", "UTC")
479+
setLocalUTC(t)
472480

473481
s := &parser.Session{
474482
ID: "tool-session-id-1234",
@@ -546,7 +554,7 @@ func TestFormatJournalEntryPart_WithToolUse(t *testing.T) {
546554
}
547555

548556
func TestFormatJournalFilename_WithSlugOverride(t *testing.T) {
549-
t.Setenv("TZ", "UTC")
557+
setLocalUTC(t)
550558

551559
s := &parser.Session{
552560
ID: "abc12345-full-session-uuid",
@@ -568,7 +576,7 @@ func TestFormatJournalFilename_WithSlugOverride(t *testing.T) {
568576
}
569577

570578
func TestFormatJournalEntryPart_SessionIDInFrontmatter(t *testing.T) {
571-
t.Setenv("TZ", "UTC")
579+
setLocalUTC(t)
572580

573581
s := &parser.Session{
574582
ID: "abc12345-full-session-uuid",
@@ -592,7 +600,7 @@ func TestFormatJournalEntryPart_SessionIDInFrontmatter(t *testing.T) {
592600
}
593601

594602
func TestFormatJournalEntryPart_TitleInFrontmatterAndHeading(t *testing.T) {
595-
t.Setenv("TZ", "UTC")
603+
setLocalUTC(t)
596604

597605
s := &parser.Session{
598606
ID: "abc12345-full-session-uuid",
@@ -625,7 +633,7 @@ func TestFormatJournalEntryPart_TitleInFrontmatterAndHeading(t *testing.T) {
625633
}
626634

627635
func TestFormatJournalEntryPart_NoTitleUsesSlug(t *testing.T) {
628-
t.Setenv("TZ", "UTC")
636+
setLocalUTC(t)
629637

630638
s := &parser.Session{
631639
ID: "abc12345-full-session-uuid",

0 commit comments

Comments
 (0)