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
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:
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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 }
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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}
|
||||
}
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
@@ -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") }
|
||||
@@ -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)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user