chore: import upstream snapshot with attribution
CI / benchmark (push) Has been skipped
install-script / posix-syntax (push) Successful in 6m1s
CI / build-onnx (push) Failing after 6m43s
init-smoke / dry-run (push) Failing after 15m57s
security / govulncheck (push) Has been cancelled
security / trivy-fs (push) Has been cancelled
CI / test (1.26, ubuntu-latest) (push) Has been cancelled
Scorecard supply-chain security / Scorecard analysis (push) Has been cancelled
CI / test (1.26, macos-latest) (push) Has been cancelled
CI / build-windows (push) Has been cancelled
CI / lint (push) Has been cancelled
install-script / powershell-syntax (push) Has been cancelled
install-script / install (macos-14) (push) Has been cancelled
install-script / install (ubuntu-latest) (push) Has been cancelled

This commit is contained in:
wehub-resource-sync
2026-07-13 12:33:42 +08:00
commit a06f331eb8
3186 changed files with 689843 additions and 0 deletions
+18
View File
@@ -0,0 +1,18 @@
//go:build !windows
package platform
import "golang.org/x/sys/unix"
// DiskAvailBytes reports the bytes available to an unprivileged caller on the
// filesystem holding path (statfs f_bavail × f_bsize — the root-reserved
// blocks are deliberately excluded, since the daemon does not run as root).
// Callers gate disk-hungry maintenance (the store VACUUM needs up to a full
// temporary copy of the database) on this number.
func DiskAvailBytes(path string) (uint64, error) {
var st unix.Statfs_t
if err := unix.Statfs(path, &st); err != nil {
return 0, err
}
return uint64(st.Bavail) * uint64(st.Bsize), nil
}
+21
View File
@@ -0,0 +1,21 @@
//go:build windows
package platform
import "golang.org/x/sys/windows"
// DiskAvailBytes reports the bytes available to the calling user on the
// volume holding path (GetDiskFreeSpaceEx's caller-quota figure, so per-user
// quotas are respected). Callers gate disk-hungry maintenance (the store
// VACUUM needs up to a full temporary copy of the database) on this number.
func DiskAvailBytes(path string) (uint64, error) {
p, err := windows.UTF16PtrFromString(path)
if err != nil {
return 0, err
}
var availToCaller, total, totalFree uint64
if err := windows.GetDiskFreeSpaceEx(p, &availToCaller, &total, &totalFree); err != nil {
return 0, err
}
return availToCaller, nil
}
+17
View File
@@ -0,0 +1,17 @@
//go:build darwin
package platform
import "golang.org/x/sys/unix"
// HostPhysicalMemoryBytes returns total physical RAM in bytes via the
// hw.memsize sysctl. Returns 0 when the sysctl is unavailable, so a caller
// that derives a budget from it can fall back cleanly to "host RAM unknown"
// rather than acting on a bogus figure.
func HostPhysicalMemoryBytes() uint64 {
n, err := unix.SysctlUint64("hw.memsize")
if err != nil {
return 0
}
return n
}
+22
View File
@@ -0,0 +1,22 @@
//go:build linux
package platform
import "golang.org/x/sys/unix"
// HostPhysicalMemoryBytes returns total physical RAM in bytes via
// sysinfo(2). Totalram is reported in units of Unit bytes; both fields are
// widened to uint64 so the multiply is correct on 32-bit arches too.
// Returns 0 when the syscall fails, so a caller that derives a budget from
// it can fall back cleanly to "host RAM unknown".
func HostPhysicalMemoryBytes() uint64 {
var si unix.Sysinfo_t
if err := unix.Sysinfo(&si); err != nil {
return 0
}
unit := uint64(si.Unit)
if unit == 0 {
unit = 1
}
return uint64(si.Totalram) * unit
}
+9
View File
@@ -0,0 +1,9 @@
//go:build !linux && !darwin
package platform
// HostPhysicalMemoryBytes has no portable reader on this platform, so it
// returns 0. A caller then treats host RAM as unknown and skips any policy
// that needs it (e.g. a RAM-derived default memory limit) rather than
// guessing. Linux and darwin carry real implementations.
func HostPhysicalMemoryBytes() uint64 { return 0 }
+26
View File
@@ -0,0 +1,26 @@
package platform
import (
"runtime"
"testing"
)
func TestHostPhysicalMemoryBytes(t *testing.T) {
got := HostPhysicalMemoryBytes()
// Reject an implausibly large figure (a malformed reader masquerading
// as an exabyte of RAM) on every platform.
const oneEiB = uint64(1) << 60
if got > oneEiB {
t.Fatalf("implausible host RAM: %d bytes", got)
}
// linux and darwin carry real readers, so a live host must report a
// non-zero figure; other platforms deliberately return 0 (unknown).
switch runtime.GOOS {
case "linux", "darwin":
if got == 0 {
t.Fatalf("expected non-zero host RAM on %s", runtime.GOOS)
}
}
}
+99
View File
@@ -0,0 +1,99 @@
package platform
import (
"os"
"path/filepath"
"strings"
)
// MigrateToUnifiedHome relocates per-user state written by older Gortex
// versions (which split files across ~/.config/gortex, ~/.cache/gortex,
// and a flat ~/.gortex) into the unified ~/.gortex tree this package now
// resolves. It is best-effort and idempotent: a destination that already
// exists is never overwritten, and individual move failures are reported
// to logf but never abort the caller.
//
// It is a no-op when any XDG_*_HOME variable is set to an absolute path —
// that signals the user opted into the XDG layout, so there is nothing to
// unify. logf may be nil. Because every step short-circuits once its
// destination exists, the function is cheap to call on every startup; it
// only logs (and only does work) on the first run after the upgrade.
func MigrateToUnifiedHome(logf func(format string, args ...any)) {
if logf == nil {
logf = func(string, ...any) {}
}
// Respect an explicit XDG opt-in: relocate nothing.
for _, v := range []string{"XDG_CONFIG_HOME", "XDG_DATA_HOME", "XDG_CACHE_HOME"} {
if val := os.Getenv(v); val != "" && filepath.IsAbs(val) {
return
}
}
home, err := os.UserHomeDir()
if err != nil || home == "" {
return
}
root := filepath.Join(home, homeDir) // ~/.gortex
// 1. Global config moves out of ~/.config/gortex into the root.
migrateInto(logf, filepath.Join(home, ".config", gortexDir, "config.yaml"), filepath.Join(root, "config.yaml"))
migrateInto(logf, filepath.Join(home, ".config", gortexDir, "servers.toml"), filepath.Join(root, "servers.toml"))
// 2. The old ~/.cache/gortex tree folds into ~/.gortex/cache, except
// downloaded models (durable data, kept out of cache so a cache
// wipe doesn't discard them) and the stale daemon socket / pid
// (regenerated on the next start).
oldCache := filepath.Join(home, ".cache", gortexDir)
if entries, err := os.ReadDir(oldCache); err == nil {
for _, e := range entries {
switch e.Name() {
case "daemon.sock", "daemon.pid":
continue
case "models":
migrateInto(logf, filepath.Join(oldCache, "models"), filepath.Join(root, "models"))
default:
migrateInto(logf, filepath.Join(oldCache, e.Name()), filepath.Join(root, cacheSub, e.Name()))
}
}
}
// 3. In-place reorg of the ~/.gortex root: the backend store (and its
// WAL/shm sidecars) move under store/, and the old memories-cache
// directory becomes memories/.
if entries, err := os.ReadDir(root); err == nil {
for _, e := range entries {
name := e.Name()
if e.IsDir() {
continue
}
if strings.HasSuffix(name, ".store") || strings.HasPrefix(name, "store.sqlite") {
migrateInto(logf, filepath.Join(root, name), filepath.Join(root, "store", name))
}
}
}
migrateInto(logf, filepath.Join(root, "memories-cache"), filepath.Join(root, "memories"))
}
// migrateInto moves src to dst when src exists and dst does not. The move
// is a rename (atomic within a filesystem); a cross-device failure is
// logged and the source left in place rather than risking a partial copy
// of a live store. Idempotent: a pre-existing dst short-circuits.
func migrateInto(logf func(string, ...any), src, dst string) {
if src == dst {
return
}
if _, err := os.Lstat(src); err != nil {
return // nothing to migrate
}
if _, err := os.Lstat(dst); err == nil {
return // already present — never clobber
}
if err := os.MkdirAll(filepath.Dir(dst), 0o755); err != nil {
logf("gortex: migrate %s: mkdir parent failed: %v", dst, err)
return
}
if err := os.Rename(src, dst); err != nil {
logf("gortex: could not migrate %s -> %s (move it manually): %v", src, dst, err)
return
}
logf("gortex: migrated %s -> %s", src, dst)
}
+91
View File
@@ -0,0 +1,91 @@
package platform
import (
"os"
"path/filepath"
"testing"
)
func seed(t *testing.T, path, content string) {
t.Helper()
if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(path, []byte(content), 0o644); err != nil {
t.Fatal(err)
}
}
// TestMigrateToUnifiedHome verifies the old split layout folds into the
// unified ~/.gortex tree, the stale socket is left behind, and a second
// run is a no-op that doesn't clobber.
func TestMigrateToUnifiedHome(t *testing.T) {
clearXDG(t)
home := t.TempDir()
t.Setenv("HOME", home)
seed(t, filepath.Join(home, ".config", "gortex", "config.yaml"), "cfg")
seed(t, filepath.Join(home, ".cache", "gortex", "daemon-sqlite.gob.gz"), "snap")
seed(t, filepath.Join(home, ".cache", "gortex", "models", "gte-small", "model.onnx"), "model")
seed(t, filepath.Join(home, ".cache", "gortex", "daemon.sock"), "sock") // ephemeral — skipped
seed(t, filepath.Join(home, ".gortex", "store.sqlite"), "db")
seed(t, filepath.Join(home, ".gortex", "store.sqlite-wal"), "wal")
seed(t, filepath.Join(home, ".gortex", "memories-cache", "global", "x.json"), "mem")
MigrateToUnifiedHome(nil)
want := map[string]string{
filepath.Join(home, ".gortex", "config.yaml"): "cfg",
filepath.Join(home, ".gortex", "cache", "daemon-sqlite.gob.gz"): "snap",
filepath.Join(home, ".gortex", "models", "gte-small", "model.onnx"): "model",
filepath.Join(home, ".gortex", "store", "store.sqlite"): "db",
filepath.Join(home, ".gortex", "store", "store.sqlite-wal"): "wal",
filepath.Join(home, ".gortex", "memories", "global", "x.json"): "mem",
}
for p, w := range want {
got, err := os.ReadFile(p)
if err != nil {
t.Errorf("expected migrated file %s: %v", p, err)
continue
}
if string(got) != w {
t.Errorf("%s = %q, want %q", p, got, w)
}
}
// The stale socket must NOT be carried into the unified cache.
if _, err := os.Lstat(filepath.Join(home, ".gortex", "cache", "daemon.sock")); err == nil {
t.Errorf("daemon.sock should have been skipped, not migrated")
}
// The old flat store file must have moved (not left behind).
if _, err := os.Lstat(filepath.Join(home, ".gortex", "store.sqlite")); err == nil {
t.Errorf("old flat store.sqlite should have moved under store/")
}
// Idempotent: a second run neither errors nor clobbers.
MigrateToUnifiedHome(nil)
if got, _ := os.ReadFile(filepath.Join(home, ".gortex", "config.yaml")); string(got) != "cfg" {
t.Errorf("config.yaml clobbered on second migration run")
}
}
// TestMigrateToUnifiedHome_SkipsUnderXDG verifies an explicit XDG opt-in
// makes migration a no-op — the user chose the XDG layout.
func TestMigrateToUnifiedHome_SkipsUnderXDG(t *testing.T) {
clearXDG(t)
home := t.TempDir()
t.Setenv("HOME", home)
t.Setenv("XDG_CONFIG_HOME", t.TempDir())
old := filepath.Join(home, ".config", "gortex", "config.yaml")
seed(t, old, "cfg")
MigrateToUnifiedHome(nil)
if _, err := os.Lstat(filepath.Join(home, ".gortex", "config.yaml")); err == nil {
t.Errorf("migration must be a no-op when an XDG override is set")
}
if _, err := os.Lstat(old); err != nil {
t.Errorf("original config must be untouched under XDG: %v", err)
}
}
+42
View File
@@ -0,0 +1,42 @@
package platform
import (
"os"
"testing"
)
func TestProcessAlive(t *testing.T) {
if !ProcessAlive(os.Getpid()) {
t.Error("ProcessAlive(self) = false, want true")
}
// Non-positive PIDs are never valid processes.
if ProcessAlive(0) {
t.Error("ProcessAlive(0) = true, want false")
}
if ProcessAlive(-1) {
t.Error("ProcessAlive(-1) = true, want false")
}
}
func TestShutdownSignals(t *testing.T) {
if len(ShutdownSignals()) == 0 {
t.Error("ShutdownSignals() returned no signals")
}
}
func TestDetachSysProcAttr(t *testing.T) {
if DetachSysProcAttr() == nil {
t.Error("DetachSysProcAttr() = nil, want non-nil SysProcAttr")
}
}
func TestTerminateAndKillGuardNonPositivePID(t *testing.T) {
// pid <= 0 must be a guarded no-op on every platform — callers rely
// on this when a PID file is missing or malformed.
if err := TerminateProcess(0); err != nil {
t.Errorf("TerminateProcess(0) = %v, want nil", err)
}
if err := KillProcess(-1); err != nil {
t.Errorf("KillProcess(-1) = %v, want nil", err)
}
}
+55
View File
@@ -0,0 +1,55 @@
//go:build !windows
// Package platform isolates the handful of OS-specific primitives Gortex
// needs at runtime — the set of signals that trigger a graceful
// shutdown, process liveness and termination, and the SysProcAttr that
// detaches a spawned daemon. Keeping these behind one package is what
// lets the rest of the tree compile unchanged on every supported OS.
package platform
import (
"os"
"syscall"
)
// ShutdownSignals returns the signals a long-running process should trap
// to begin a graceful shutdown. On Unix that's SIGINT (Ctrl-C) and
// SIGTERM (the default `kill` / supervisor stop signal).
func ShutdownSignals() []os.Signal {
return []os.Signal{syscall.SIGINT, syscall.SIGTERM}
}
// ProcessAlive reports whether a process with the given PID currently
// exists. Signalling 0 is the canonical Unix liveness probe: it runs
// every permission check but delivers nothing, so a nil error means the
// process is there (and reachable).
func ProcessAlive(pid int) bool {
if pid <= 0 {
return false
}
return syscall.Kill(pid, 0) == nil
}
// TerminateProcess asks the process to exit gracefully (SIGTERM).
func TerminateProcess(pid int) error {
if pid <= 0 {
return nil
}
return syscall.Kill(pid, syscall.SIGTERM)
}
// KillProcess forcibly terminates the process (SIGKILL).
func KillProcess(pid int) error {
if pid <= 0 {
return nil
}
return syscall.Kill(pid, syscall.SIGKILL)
}
// DetachSysProcAttr returns the SysProcAttr that detaches a spawned
// child from the parent's controlling terminal — Setsid puts the child
// in its own session, so Ctrl-C in the parent shell isn't forwarded to
// the daemon.
func DetachSysProcAttr() *syscall.SysProcAttr {
return &syscall.SysProcAttr{Setsid: true}
}
+71
View File
@@ -0,0 +1,71 @@
//go:build windows
package platform
import (
"os"
"syscall"
"golang.org/x/sys/windows"
)
// stillActive is the exit code GetExitCodeProcess reports while a
// process is still running (Win32 STILL_ACTIVE / STATUS_PENDING).
const stillActive = 259
// ShutdownSignals returns the signals a long-running process should trap
// to begin a graceful shutdown. Windows has no SIGTERM; os.Interrupt —
// delivered for Ctrl-C and Ctrl-Break — is the only portable trigger.
func ShutdownSignals() []os.Signal {
return []os.Signal{os.Interrupt}
}
// ProcessAlive reports whether a process with the given PID currently
// exists and has not yet exited. It opens a query handle and inspects
// the exit code: only a still-running process reports STILL_ACTIVE.
func ProcessAlive(pid int) bool {
if pid <= 0 {
return false
}
h, err := windows.OpenProcess(windows.PROCESS_QUERY_LIMITED_INFORMATION, false, uint32(pid))
if err != nil {
return false
}
defer windows.CloseHandle(h) //nolint:errcheck // best-effort handle close
var code uint32
if err := windows.GetExitCodeProcess(h, &code); err != nil {
return false
}
return code == stillActive
}
// TerminateProcess asks the process to exit. Windows offers no graceful
// signal a console-less detached process can receive, so this is a hard
// TerminateProcess — the same as KillProcess. The daemon's preferred
// stop path is the control-socket RPC; this is only the fallback for a
// daemon that no longer answers the socket.
func TerminateProcess(pid int) error {
return KillProcess(pid)
}
// KillProcess forcibly terminates the process.
func KillProcess(pid int) error {
if pid <= 0 {
return nil
}
p, err := os.FindProcess(pid)
if err != nil {
return err
}
return p.Kill()
}
// DetachSysProcAttr returns the SysProcAttr that fully detaches a
// spawned child: a new process group, so a Ctrl-C in the parent console
// isn't forwarded, plus DETACHED_PROCESS so the child runs with no
// inherited console.
func DetachSysProcAttr() *syscall.SysProcAttr {
return &syscall.SysProcAttr{
CreationFlags: windows.CREATE_NEW_PROCESS_GROUP | windows.DETACHED_PROCESS,
}
}
+98
View File
@@ -0,0 +1,98 @@
package platform
import (
"os"
"path/filepath"
)
// Gortex keeps all per-user state under one directory tree. By default
// that tree is $HOME/.gortex, holding config, cache, the on-disk store,
// downloaded models, and development memories side by side — a single
// place to find, back up, or delete.
//
// The XDG Base Directory variables remain an explicit escape hatch:
// when XDG_CONFIG_HOME / XDG_DATA_HOME / XDG_CACHE_HOME is set to an
// absolute path it wins, and that category's files live under
// "<XDG_*_HOME>/gortex" (standard XDG layout) instead of inside the
// unified ~/.gortex tree. This keeps XDG-strict setups, sandboxes, and
// the test suite working while giving everyone else one folder.
const (
// gortexDir is the application sub-directory Gortex owns inside an
// XDG base directory when an XDG_*_HOME override is in effect.
gortexDir = "gortex"
// homeDir is the unified per-user directory ($HOME/.gortex) used
// when no XDG override applies.
homeDir = ".gortex"
// cacheSub disambiguates cache from config/data inside the unified
// ~/.gortex tree. Under an XDG_CACHE_HOME override the base is
// already cache-specific, so this sub-path is not added there.
cacheSub = "cache"
)
// Home returns the unified per-user Gortex directory ($HOME/.gortex),
// falling back to a temp-dir equivalent when $HOME can't be resolved.
// This is the root the cache / store / models / memories sub-paths hang
// off when no XDG override is in play.
func Home() string {
home, err := os.UserHomeDir()
if err != nil || home == "" {
return filepath.Join(os.TempDir(), homeDir)
}
return filepath.Join(home, homeDir)
}
// unifiedDir resolves a Gortex base for one XDG category. An absolute
// $envVar wins ("<envVar>/gortex" — the standard XDG location), so
// XDG-strict setups, sandboxes, and the test suite keep working.
// Otherwise the category collapses into the unified ~/.gortex tree,
// with homeSub distinguishing cache ("cache") from config/data ("").
//
// A non-absolute $envVar is ignored, as the XDG Base Directory
// specification mandates ("If [the variable] is set to a relative path
// the value MUST be ignored").
func unifiedDir(envVar, homeSub string) string {
if v := os.Getenv(envVar); v != "" && filepath.IsAbs(v) {
return filepath.Join(v, gortexDir)
}
return filepath.Join(Home(), homeSub)
}
// ConfigDir is where Gortex reads/writes configuration (config.yaml,
// servers.toml). Default ~/.gortex; an absolute $XDG_CONFIG_HOME
// relocates it to "<XDG_CONFIG_HOME>/gortex".
func ConfigDir() string { return unifiedDir("XDG_CONFIG_HOME", "") }
// DataDir is the root for durable, non-disposable state (the on-disk
// store, downloaded models, development memories). Default ~/.gortex;
// an absolute $XDG_DATA_HOME relocates it to "<XDG_DATA_HOME>/gortex".
func DataDir() string { return unifiedDir("XDG_DATA_HOME", "") }
// CacheDir is where Gortex keeps disposable state (the daemon socket /
// pid / log, snapshots, eval and token caches). Default ~/.gortex/cache;
// an absolute $XDG_CACHE_HOME relocates it to "<XDG_CACHE_HOME>/gortex".
func CacheDir() string { return unifiedDir("XDG_CACHE_HOME", cacheSub) }
// OSCacheDir is retained for callers that historically rooted their
// cache at os.UserCacheDir(); under the unified layout it resolves to
// the same directory as CacheDir.
func OSCacheDir() string { return CacheDir() }
// StoreDir is where the on-disk backend persists its store:
// <DataDir>/store (~/.gortex/store by default).
func StoreDir() string { return filepath.Join(DataDir(), "store") }
// ModelsDir is where downloaded embedding models live: <DataDir>/models
// (~/.gortex/models by default). Models live under DataDir rather than
// CacheDir so a cache wipe doesn't discard multi-hundred-MB downloads.
func ModelsDir() string { return filepath.Join(DataDir(), "models") }
// MemoriesDir is where cross-session development memories persist:
// <DataDir>/memories (~/.gortex/memories by default).
func MemoriesDir() string { return filepath.Join(DataDir(), "memories") }
// TelemetryDir is where opt-in anonymous usage telemetry buffers its daily
// rollups and stores the install id: <DataDir>/telemetry (~/.gortex/telemetry
// by default). Under DataDir (durable) rather than CacheDir so a cache wipe
// doesn't silently reset the stable install id or drop unsent completed days.
func TelemetryDir() string { return filepath.Join(DataDir(), "telemetry") }
+219
View File
@@ -0,0 +1,219 @@
package platform
import (
"path/filepath"
"testing"
)
// clearXDG unsets every XDG base-directory variable so a test starts
// from a known clean slate; t.Setenv restores the prior value at the
// end of the test.
func clearXDG(t *testing.T) {
t.Helper()
for _, v := range []string{"XDG_CONFIG_HOME", "XDG_DATA_HOME", "XDG_CACHE_HOME"} {
t.Setenv(v, "")
}
}
// TestHome verifies the unified per-user directory is $HOME/.gortex.
func TestHome(t *testing.T) {
clearXDG(t)
home := t.TempDir()
t.Setenv("HOME", home)
if got, want := Home(), filepath.Join(home, ".gortex"); got != want {
t.Errorf("Home() = %s, want %s", got, want)
}
}
// TestConfigDir_HonorsXDGConfigHome verifies an absolute $XDG_CONFIG_HOME
// relocates config to the standard XDG location.
func TestConfigDir_HonorsXDGConfigHome(t *testing.T) {
clearXDG(t)
xdg := t.TempDir()
t.Setenv("XDG_CONFIG_HOME", xdg)
want := filepath.Join(xdg, "gortex")
if got := ConfigDir(); got != want {
t.Errorf("ConfigDir() = %s, want %s", got, want)
}
}
// TestConfigDir_UnsetFallback verifies the env-unset default is the
// unified $HOME/.gortex directory.
func TestConfigDir_UnsetFallback(t *testing.T) {
clearXDG(t)
home := t.TempDir()
t.Setenv("HOME", home)
want := filepath.Join(home, ".gortex")
if got := ConfigDir(); got != want {
t.Errorf("ConfigDir() = %s, want %s (unified default)", got, want)
}
}
// TestDataDir_HonorsXDGDataHome verifies an absolute $XDG_DATA_HOME
// relocates data to the standard XDG location.
func TestDataDir_HonorsXDGDataHome(t *testing.T) {
clearXDG(t)
xdg := t.TempDir()
t.Setenv("XDG_DATA_HOME", xdg)
want := filepath.Join(xdg, "gortex")
if got := DataDir(); got != want {
t.Errorf("DataDir() = %s, want %s", got, want)
}
}
// TestDataDir_UnsetFallback verifies the env-unset default collapses
// into the unified $HOME/.gortex directory.
func TestDataDir_UnsetFallback(t *testing.T) {
clearXDG(t)
home := t.TempDir()
t.Setenv("HOME", home)
want := filepath.Join(home, ".gortex")
if got := DataDir(); got != want {
t.Errorf("DataDir() = %s, want %s (unified default)", got, want)
}
}
// TestTelemetryDir verifies the telemetry buffer lives under DataDir, both for
// the unified default and an absolute $XDG_DATA_HOME relocation.
func TestTelemetryDir(t *testing.T) {
clearXDG(t)
home := t.TempDir()
t.Setenv("HOME", home)
if got, want := TelemetryDir(), filepath.Join(home, ".gortex", "telemetry"); got != want {
t.Errorf("TelemetryDir() = %s, want %s (unified default)", got, want)
}
xdg := t.TempDir()
t.Setenv("XDG_DATA_HOME", xdg)
if got, want := TelemetryDir(), filepath.Join(xdg, "gortex", "telemetry"); got != want {
t.Errorf("TelemetryDir() = %s, want %s (XDG)", got, want)
}
}
// TestCacheDir_HonorsXDGCacheHome verifies an absolute $XDG_CACHE_HOME
// relocates cache to the standard XDG location.
func TestCacheDir_HonorsXDGCacheHome(t *testing.T) {
clearXDG(t)
xdg := t.TempDir()
t.Setenv("XDG_CACHE_HOME", xdg)
want := filepath.Join(xdg, "gortex")
if got := CacheDir(); got != want {
t.Errorf("CacheDir() = %s, want %s", got, want)
}
}
// TestCacheDir_UnsetFallback verifies the env-unset default is the
// cache/ sub-directory inside the unified ~/.gortex tree.
func TestCacheDir_UnsetFallback(t *testing.T) {
clearXDG(t)
home := t.TempDir()
t.Setenv("HOME", home)
want := filepath.Join(home, ".gortex", "cache")
if got := CacheDir(); got != want {
t.Errorf("CacheDir() = %s, want %s (unified default)", got, want)
}
}
// TestOSCacheDir_ConvergesWithCacheDir verifies OSCacheDir now resolves
// to the same place as CacheDir under both an XDG override and the
// unified default.
func TestOSCacheDir_ConvergesWithCacheDir(t *testing.T) {
clearXDG(t)
home := t.TempDir()
t.Setenv("HOME", home)
if got, want := OSCacheDir(), CacheDir(); got != want {
t.Errorf("OSCacheDir() = %s, want %s (must converge with CacheDir)", got, want)
}
if got, want := OSCacheDir(), filepath.Join(home, ".gortex", "cache"); got != want {
t.Errorf("OSCacheDir() unified = %s, want %s", got, want)
}
xdg := t.TempDir()
t.Setenv("XDG_CACHE_HOME", xdg)
if got, want := OSCacheDir(), filepath.Join(xdg, "gortex"); got != want {
t.Errorf("OSCacheDir() with XDG_CACHE_HOME = %s, want %s", got, want)
}
}
// TestPurposeDirs_UnsetFallback verifies the store / models / memories
// sub-directories hang off the unified ~/.gortex tree by default.
func TestPurposeDirs_UnsetFallback(t *testing.T) {
clearXDG(t)
home := t.TempDir()
t.Setenv("HOME", home)
cases := []struct {
name string
got func() string
want string
}{
{"store", StoreDir, filepath.Join(home, ".gortex", "store")},
{"models", ModelsDir, filepath.Join(home, ".gortex", "models")},
{"memories", MemoriesDir, filepath.Join(home, ".gortex", "memories")},
}
for _, tc := range cases {
if got := tc.got(); got != tc.want {
t.Errorf("%sDir() = %s, want %s", tc.name, got, tc.want)
}
}
}
// TestPurposeDirs_HonorXDGDataHome verifies the purpose sub-directories
// follow an absolute $XDG_DATA_HOME into the standard XDG layout.
func TestPurposeDirs_HonorXDGDataHome(t *testing.T) {
clearXDG(t)
xdg := t.TempDir()
t.Setenv("XDG_DATA_HOME", xdg)
cases := []struct {
name string
got func() string
want string
}{
{"store", StoreDir, filepath.Join(xdg, "gortex", "store")},
{"models", ModelsDir, filepath.Join(xdg, "gortex", "models")},
{"memories", MemoriesDir, filepath.Join(xdg, "gortex", "memories")},
}
for _, tc := range cases {
if got := tc.got(); got != tc.want {
t.Errorf("%sDir() = %s, want %s", tc.name, got, tc.want)
}
}
}
// TestNonAbsoluteXDGIgnored verifies a relative XDG_*_HOME value is
// ignored, as the XDG Base Directory specification mandates — the
// resolver falls back to the unified $HOME/.gortex default instead.
func TestNonAbsoluteXDGIgnored(t *testing.T) {
clearXDG(t)
home := t.TempDir()
t.Setenv("HOME", home)
cases := []struct {
name string
envVar string
relVal string
got func() string
want string
}{
{"config", "XDG_CONFIG_HOME", "relative/config", ConfigDir, filepath.Join(home, ".gortex")},
{"data", "XDG_DATA_HOME", "relative/data", DataDir, filepath.Join(home, ".gortex")},
{"cache", "XDG_CACHE_HOME", "relative/cache", CacheDir, filepath.Join(home, ".gortex", "cache")},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
t.Setenv(tc.envVar, tc.relVal)
if got := tc.got(); got != tc.want {
t.Errorf("%s with relative %s=%q: got %s, want %s (relative value must be ignored)",
tc.name, tc.envVar, tc.relVal, got, tc.want)
}
})
}
}