diff --git a/contract.go b/contract.go index ddf0def..b5fd099 100644 --- a/contract.go +++ b/contract.go @@ -82,6 +82,7 @@ func New(opts ...Options) *Core { log: &ErrorLog{log: Default()}, lock: &Lock{}, ipc: &Ipc{}, + info: systemInfo, i18n: &I18n{}, services: &serviceRegistry{services: make(map[string]*Service)}, commands: &commandRegistry{commands: make(map[string]*Command)}, diff --git a/core.go b/core.go index b02fc93..fb9c5d9 100644 --- a/core.go +++ b/core.go @@ -28,6 +28,7 @@ type Core struct { services *serviceRegistry // c.Service("name") — Service registry lock *Lock // c.Lock("name") — Named mutexes ipc *Ipc // c.IPC() — Message bus for IPC + info *SysInfo // c.Env("key") — Read-only system/environment information i18n *I18n // c.I18n() — Internationalisation and locale collection context context.Context @@ -51,6 +52,7 @@ func (c *Core) Log() *ErrorLog { return c.log } func (c *Core) Cli() *Cli { return c.cli } func (c *Core) IPC() *Ipc { return c.ipc } func (c *Core) I18n() *I18n { return c.i18n } +func (c *Core) Env(key string) string { return Env(key) } func (c *Core) Context() context.Context { return c.context } func (c *Core) Core() *Core { return c } diff --git a/info.go b/info.go new file mode 100644 index 0000000..1a4ae43 --- /dev/null +++ b/info.go @@ -0,0 +1,134 @@ +// SPDX-License-Identifier: EUPL-1.2 + +// System information registry for the Core framework. +// Read-only key-value store of environment facts, populated once at init. +// Env is environment. Config is ours. +// +// System keys: +// +// core.Env("OS") // "darwin" +// core.Env("ARCH") // "arm64" +// core.Env("GO") // "go1.26" +// core.Env("DS") // "/" (directory separator) +// core.Env("PS") // ":" (path list separator) +// core.Env("HOSTNAME") // "cladius" +// core.Env("USER") // "snider" +// core.Env("PID") // "12345" +// core.Env("NUM_CPU") // "10" +// +// Directory keys: +// +// core.Env("DIR_HOME") // "/Users/snider" +// core.Env("DIR_CONFIG") // "~/Library/Application Support" +// core.Env("DIR_CACHE") // "~/Library/Caches" +// core.Env("DIR_DATA") // "~/Library" (platform-specific) +// core.Env("DIR_TMP") // "/tmp" +// core.Env("DIR_CWD") // current working directory +// core.Env("DIR_DOWNLOADS") // "~/Downloads" +// core.Env("DIR_CODE") // "~/Code" +// +// Timestamp keys: +// +// core.Env("CORE_START") // "2026-03-22T14:30:00Z" +package core + +import ( + "os" + "runtime" + "strconv" + "time" +) + +// SysInfo holds read-only system information, populated once at init. +type SysInfo struct { + values map[string]string +} + +// systemInfo is declared empty — populated in init() so Path() can be used +// without creating an init cycle. +var systemInfo = &SysInfo{values: make(map[string]string)} + +func init() { + i := systemInfo + + // System + i.values["OS"] = runtime.GOOS + i.values["ARCH"] = runtime.GOARCH + i.values["GO"] = runtime.Version() + i.values["DS"] = string(os.PathSeparator) + i.values["PS"] = string(os.PathListSeparator) + i.values["PID"] = strconv.Itoa(os.Getpid()) + i.values["NUM_CPU"] = strconv.Itoa(runtime.NumCPU()) + i.values["USER"] = Username() + + if h, err := os.Hostname(); err == nil { + i.values["HOSTNAME"] = h + } + + // Directories — DS and DIR_HOME set first so Path() can use them. + // CORE_HOME overrides os.UserHomeDir() (e.g., agent workspaces). + if d := os.Getenv("CORE_HOME"); d != "" { + i.values["DIR_HOME"] = d + } else if d, err := os.UserHomeDir(); err == nil { + i.values["DIR_HOME"] = d + } + + // Derived directories via Path() — single point of responsibility + i.values["DIR_DOWNLOADS"] = Path("Downloads") + i.values["DIR_CODE"] = Path("Code") + if d, err := os.UserConfigDir(); err == nil { + i.values["DIR_CONFIG"] = d + } + if d, err := os.UserCacheDir(); err == nil { + i.values["DIR_CACHE"] = d + } + i.values["DIR_TMP"] = os.TempDir() + if d, err := os.Getwd(); err == nil { + i.values["DIR_CWD"] = d + } + + // Platform-specific data directory + switch runtime.GOOS { + case "darwin": + i.values["DIR_DATA"] = Path(Env("DIR_HOME"), "Library") + case "windows": + if d := os.Getenv("LOCALAPPDATA"); d != "" { + i.values["DIR_DATA"] = d + } + default: + if xdg := os.Getenv("XDG_DATA_HOME"); xdg != "" { + i.values["DIR_DATA"] = xdg + } else if Env("DIR_HOME") != "" { + i.values["DIR_DATA"] = Path(Env("DIR_HOME"), ".local", "share") + } + } + + // Timestamps + i.values["CORE_START"] = time.Now().UTC().Format(time.RFC3339) +} + +// Env returns a system information value by key. +// Core keys (OS, DIR_HOME, DS, etc.) are pre-populated at init. +// Unknown keys fall through to os.Getenv — making Env a universal +// replacement for os.Getenv. +// +// core.Env("OS") // "darwin" (pre-populated) +// core.Env("DIR_HOME") // "/Users/snider" (pre-populated) +// core.Env("FORGE_TOKEN") // falls through to os.Getenv +func Env(key string) string { + if v := systemInfo.values[key]; v != "" { + return v + } + return os.Getenv(key) +} + +// EnvKeys returns all available environment keys. +// +// keys := core.EnvKeys() +func EnvKeys() []string { + keys := make([]string, 0, len(systemInfo.values)) + for k := range systemInfo.values { + keys = append(keys, k) + } + return keys +} diff --git a/info_test.go b/info_test.go new file mode 100644 index 0000000..5f09db7 --- /dev/null +++ b/info_test.go @@ -0,0 +1,103 @@ +// SPDX-License-Identifier: EUPL-1.2 + +package core_test + +import ( + "os" + "runtime" + "testing" + "time" + + core "dappco.re/go/core" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestEnv_OS(t *testing.T) { + assert.Equal(t, runtime.GOOS, core.Env("OS")) +} + +func TestEnv_ARCH(t *testing.T) { + assert.Equal(t, runtime.GOARCH, core.Env("ARCH")) +} + +func TestEnv_GO(t *testing.T) { + assert.Equal(t, runtime.Version(), core.Env("GO")) +} + +func TestEnv_DS(t *testing.T) { + assert.Equal(t, string(os.PathSeparator), core.Env("DS")) +} + +func TestEnv_PS(t *testing.T) { + assert.Equal(t, string(os.PathListSeparator), core.Env("PS")) +} + +func TestEnv_DIR_HOME(t *testing.T) { + if ch := os.Getenv("CORE_HOME"); ch != "" { + assert.Equal(t, ch, core.Env("DIR_HOME")) + return + } + home, err := os.UserHomeDir() + require.NoError(t, err) + assert.Equal(t, home, core.Env("DIR_HOME")) +} + +func TestEnv_DIR_TMP(t *testing.T) { + assert.Equal(t, os.TempDir(), core.Env("DIR_TMP")) +} + +func TestEnv_DIR_CONFIG(t *testing.T) { + cfg, err := os.UserConfigDir() + require.NoError(t, err) + assert.Equal(t, cfg, core.Env("DIR_CONFIG")) +} + +func TestEnv_DIR_CACHE(t *testing.T) { + cache, err := os.UserCacheDir() + require.NoError(t, err) + assert.Equal(t, cache, core.Env("DIR_CACHE")) +} + +func TestEnv_HOSTNAME(t *testing.T) { + hostname, err := os.Hostname() + require.NoError(t, err) + assert.Equal(t, hostname, core.Env("HOSTNAME")) +} + +func TestEnv_USER(t *testing.T) { + assert.NotEmpty(t, core.Env("USER")) +} + +func TestEnv_PID(t *testing.T) { + assert.NotEmpty(t, core.Env("PID")) +} + +func TestEnv_NUM_CPU(t *testing.T) { + assert.NotEmpty(t, core.Env("NUM_CPU")) +} + +func TestEnv_CORE_START(t *testing.T) { + ts := core.Env("CORE_START") + require.NotEmpty(t, ts) + _, err := time.Parse(time.RFC3339, ts) + assert.NoError(t, err, "CORE_START should be valid RFC3339") +} + +func TestEnv_Unknown(t *testing.T) { + assert.Equal(t, "", core.Env("NOPE")) +} + +func TestEnv_CoreInstance(t *testing.T) { + c := core.New() + assert.Equal(t, core.Env("OS"), c.Env("OS")) + assert.Equal(t, core.Env("DIR_HOME"), c.Env("DIR_HOME")) +} + +func TestEnvKeys(t *testing.T) { + keys := core.EnvKeys() + assert.NotEmpty(t, keys) + assert.Contains(t, keys, "OS") + assert.Contains(t, keys, "DIR_HOME") + assert.Contains(t, keys, "CORE_START") +} diff --git a/path.go b/path.go new file mode 100644 index 0000000..d977e9c --- /dev/null +++ b/path.go @@ -0,0 +1,174 @@ +// SPDX-License-Identifier: EUPL-1.2 + +// OS-aware filesystem path operations for the Core framework. +// Uses Env("DS") for the separator and Core string primitives +// for path manipulation. filepath imported only for PathGlob. +// +// Path anchors relative segments to DIR_HOME: +// +// core.Path("Code", ".core") // "/Users/snider/Code/.core" +// core.Path("/tmp", "workspace") // "/tmp/workspace" +// core.Path() // "/Users/snider" +// +// Path component helpers: +// +// core.PathBase("/Users/snider/Code/core") // "core" +// core.PathDir("/Users/snider/Code/core") // "/Users/snider/Code" +// core.PathExt("main.go") // ".go" +package core + +import "path/filepath" + +// Path builds a clean, absolute filesystem path from segments. +// Uses Env("DS") for the OS directory separator. +// Relative paths are anchored to DIR_HOME. Absolute paths pass through. +// +// core.Path("Code", ".core") // "/Users/snider/Code/.core" +// core.Path("/tmp", "workspace") // "/tmp/workspace" +// core.Path() // "/Users/snider" +func Path(segments ...string) string { + ds := Env("DS") + home := Env("DIR_HOME") + if home == "" { + home = "." + } + if len(segments) == 0 { + return home + } + p := Join(ds, segments...) + if PathIsAbs(p) { + return CleanPath(p, ds) + } + return CleanPath(home+ds+p, ds) +} + +// PathBase returns the last element of a path. +// +// core.PathBase("/Users/snider/Code/core") // "core" +// core.PathBase("deploy/to/homelab") // "homelab" +func PathBase(p string) string { + if p == "" { + return "." + } + ds := Env("DS") + p = TrimSuffix(p, ds) + if p == "" { + return ds + } + parts := Split(p, ds) + return parts[len(parts)-1] +} + +// PathDir returns all but the last element of a path. +// +// core.PathDir("/Users/snider/Code/core") // "/Users/snider/Code" +func PathDir(p string) string { + if p == "" { + return "." + } + ds := Env("DS") + i := lastIndex(p, ds) + if i < 0 { + return "." + } + dir := p[:i] + if dir == "" { + return ds + } + return dir +} + +// PathExt returns the file extension including the dot. +// +// core.PathExt("main.go") // ".go" +// core.PathExt("Makefile") // "" +func PathExt(p string) string { + base := PathBase(p) + i := lastIndex(base, ".") + if i <= 0 { + return "" + } + return base[i:] +} + +// PathIsAbs returns true if the path is absolute. +// Handles Unix (starts with /) and Windows (drive letter like C:). +// +// core.PathIsAbs("/tmp") // true +// core.PathIsAbs("C:\\tmp") // true +// core.PathIsAbs("relative") // false +func PathIsAbs(p string) bool { + if p == "" { + return false + } + if p[0] == '/' { + return true + } + // Windows: C:\ or C:/ + if len(p) >= 3 && p[1] == ':' && (p[2] == '/' || p[2] == '\\') { + return true + } + return false +} + +// CleanPath removes redundant separators and resolves . and .. elements. +// +// core.CleanPath("/tmp//file", "/") // "/tmp/file" +// core.CleanPath("a/b/../c", "/") // "a/c" +func CleanPath(p, ds string) string { + if p == "" { + return "." + } + + rooted := HasPrefix(p, ds) + parts := Split(p, ds) + var clean []string + + for _, part := range parts { + switch part { + case "", ".": + continue + case "..": + if len(clean) > 0 && clean[len(clean)-1] != ".." { + clean = clean[:len(clean)-1] + } else if !rooted { + clean = append(clean, "..") + } + default: + clean = append(clean, part) + } + } + + result := Join(ds, clean...) + if rooted { + result = ds + result + } + if result == "" { + if rooted { + return ds + } + return "." + } + return result +} + +// PathGlob returns file paths matching a pattern. +// +// core.PathGlob("/tmp/agent-*.log") +func PathGlob(pattern string) []string { + matches, _ := filepath.Glob(pattern) + return matches +} + +// lastIndex returns the index of the last occurrence of substr in s, or -1. +func lastIndex(s, substr string) int { + if substr == "" || s == "" { + return -1 + } + for i := len(s) - len(substr); i >= 0; i-- { + if s[i:i+len(substr)] == substr { + return i + } + } + return -1 +} diff --git a/path_test.go b/path_test.go index ad78e5b..fdc8725 100644 --- a/path_test.go +++ b/path_test.go @@ -106,7 +106,6 @@ func TestCleanPath_Good(t *testing.T) { } func TestPathDir_TrailingSlash(t *testing.T) { - // Trailing slash is stripped, then dir of /Users/snider/Code = /Users/snider result := core.PathDir("/Users/snider/Code/") assert.Equal(t, "/Users/snider/Code", result) }