diff --git a/info.go b/info.go index 323fbb8..1a4ae43 100644 --- a/info.go +++ b/info.go @@ -44,10 +44,12 @@ type SysInfo struct { values map[string]string } -var systemInfo = newSysInfo() +// 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 newSysInfo() *SysInfo { - i := &SysInfo{values: make(map[string]string)} +func init() { + i := systemInfo // System i.values["OS"] = runtime.GOOS @@ -63,12 +65,17 @@ func newSysInfo() *SysInfo { i.values["HOSTNAME"] = h } - // Directories - if d, err := os.UserHomeDir(); err == nil { + // 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 - i.values["DIR_DOWNLOADS"] = d + string(os.PathSeparator) + "Downloads" - i.values["DIR_CODE"] = d + string(os.PathSeparator) + "Code" } + + // 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 } @@ -83,7 +90,7 @@ func newSysInfo() *SysInfo { // Platform-specific data directory switch runtime.GOOS { case "darwin": - i.values["DIR_DATA"] = i.values["DIR_HOME"] + "/Library" + i.values["DIR_DATA"] = Path(Env("DIR_HOME"), "Library") case "windows": if d := os.Getenv("LOCALAPPDATA"); d != "" { i.values["DIR_DATA"] = d @@ -91,25 +98,28 @@ func newSysInfo() *SysInfo { default: if xdg := os.Getenv("XDG_DATA_HOME"); xdg != "" { i.values["DIR_DATA"] = xdg - } else if home := i.values["DIR_HOME"]; home != "" { - i.values["DIR_DATA"] = home + "/.local/share" + } 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) - - return i } // Env returns a system information value by key. -// Returns empty string for unknown keys. +// 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" -// core.Env("DIR_HOME") // "/Users/snider" -// core.Env("DS") // "/" +// 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 { - return systemInfo.values[key] + if v := systemInfo.values[key]; v != "" { + return v + } + return os.Getenv(key) } // EnvKeys returns all available environment keys. diff --git a/path.go b/path.go new file mode 100644 index 0000000..023419d --- /dev/null +++ b/path.go @@ -0,0 +1,171 @@ +// SPDX-License-Identifier: EUPL-1.2 + +// OS-aware filesystem path operations for the Core framework. +// Zero filepath import — uses Env("DS") for the separator and +// Core string primitives for all path manipulation. +// +// 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 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 new file mode 100644 index 0000000..56a3ac7 --- /dev/null +++ b/path_test.go @@ -0,0 +1,75 @@ +// SPDX-License-Identifier: EUPL-1.2 + +package core_test + +import ( + "os" + "testing" + + core "dappco.re/go/core" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestPath_Relative(t *testing.T) { + home, err := os.UserHomeDir() + require.NoError(t, err) + ds := core.Env("DS") + assert.Equal(t, home+ds+"Code"+ds+".core", core.Path("Code", ".core")) +} + +func TestPath_Absolute(t *testing.T) { + assert.Equal(t, "/tmp/workspace", core.Path("/tmp", "workspace")) +} + +func TestPath_Empty(t *testing.T) { + home, err := os.UserHomeDir() + require.NoError(t, err) + assert.Equal(t, home, core.Path()) +} + +func TestPath_Cleans(t *testing.T) { + home, err := os.UserHomeDir() + require.NoError(t, err) + assert.Equal(t, home+core.Env("DS")+"Code", core.Path("Code", "sub", "..")) +} + +func TestPath_CleanDoubleSlash(t *testing.T) { + assert.Equal(t, "/tmp/file", core.Path("/tmp//file")) +} + +func TestPathBase(t *testing.T) { + assert.Equal(t, "core", core.PathBase("/Users/snider/Code/core")) + assert.Equal(t, "homelab", core.PathBase("deploy/to/homelab")) +} + +func TestPathBase_Root(t *testing.T) { + assert.Equal(t, "/", core.PathBase("/")) +} + +func TestPathBase_Empty(t *testing.T) { + assert.Equal(t, ".", core.PathBase("")) +} + +func TestPathDir(t *testing.T) { + assert.Equal(t, "/Users/snider/Code", core.PathDir("/Users/snider/Code/core")) +} + +func TestPathDir_Root(t *testing.T) { + assert.Equal(t, "/", core.PathDir("/file")) +} + +func TestPathDir_NoDir(t *testing.T) { + assert.Equal(t, ".", core.PathDir("file.go")) +} + +func TestPathExt(t *testing.T) { + assert.Equal(t, ".go", core.PathExt("main.go")) + assert.Equal(t, "", core.PathExt("Makefile")) + assert.Equal(t, ".gz", core.PathExt("archive.tar.gz")) +} + +func TestPath_EnvConsistency(t *testing.T) { + // Path() and Env("DIR_HOME") should agree + assert.Equal(t, core.Env("DIR_HOME"), core.Path()) +}