diff --git a/pkg/path/expand.go b/pkg/path/expand.go new file mode 100644 index 000000000..c6d4083c2 --- /dev/null +++ b/pkg/path/expand.go @@ -0,0 +1,31 @@ +package path + +import ( + "os" + "path/filepath" + "strings" +) + +// ExpandPath expands shell-like patterns in a file path: +// - ~ or ~/ at the start is replaced with the user's home directory +// - Environment variables like ${HOME} or $HOME are expanded +func ExpandPath(p string) string { + if p == "" { + return p + } + + // Expand environment variables + p = os.ExpandEnv(p) + + // Expand tilde to home directory + if p == "~" || strings.HasPrefix(p, "~/") || strings.HasPrefix(p, "~"+string(filepath.Separator)) { + if home, err := os.UserHomeDir(); err == nil { + if p == "~" { + return home + } + return filepath.Join(home, p[2:]) + } + } + + return p +} diff --git a/pkg/path/expand_test.go b/pkg/path/expand_test.go new file mode 100644 index 000000000..b15090a67 --- /dev/null +++ b/pkg/path/expand_test.go @@ -0,0 +1,76 @@ +package path + +import ( + "os" + "path/filepath" + "testing" +) + +func TestExpandPath(t *testing.T) { + home, err := os.UserHomeDir() + if err != nil { + t.Fatal(err) + } + + tests := []struct { + name string + input string + envSetup map[string]string + expected string + }{ + { + name: "empty path", + input: "", + expected: "", + }, + { + name: "tilde only", + input: "~", + expected: home, + }, + { + name: "tilde with subpath", + input: "~/data/memory.db", + expected: filepath.Join(home, "data", "memory.db"), + }, + { + name: "env var", + input: "${HOME}/.data/memory.db", + expected: filepath.Join(home, ".data", "memory.db"), + }, + { + name: "custom env var", + input: "${MY_TEST_DATA_DIR}/memory.db", + envSetup: map[string]string{"MY_TEST_DATA_DIR": "/tmp/testdata"}, + expected: "/tmp/testdata/memory.db", + }, + { + name: "absolute path unchanged", + input: "/absolute/path/memory.db", + expected: "/absolute/path/memory.db", + }, + { + name: "relative path unchanged", + input: "relative/path/memory.db", + expected: "relative/path/memory.db", + }, + { + name: "tilde and env var combined", + input: "~/${MY_TEST_SUBDIR}/memory.db", + envSetup: map[string]string{"MY_TEST_SUBDIR": "data"}, + expected: filepath.Join(home, "data", "memory.db"), + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + for k, v := range tt.envSetup { + t.Setenv(k, v) + } + result := ExpandPath(tt.input) + if result != tt.expected { + t.Errorf("ExpandPath(%q) = %q, want %q", tt.input, result, tt.expected) + } + }) + } +} diff --git a/pkg/teamloader/registry.go b/pkg/teamloader/registry.go index 8f39572a7..67118b118 100644 --- a/pkg/teamloader/registry.go +++ b/pkg/teamloader/registry.go @@ -85,6 +85,23 @@ func NewDefaultToolsetRegistry() *ToolsetRegistry { return r } +// resolveToolsetPath expands shell patterns (~, env vars) in the given path, +// then validates it relative to the working directory or parent directory. +func resolveToolsetPath(toolsetPath, parentDir string, runConfig *config.RuntimeConfig) (string, error) { + toolsetPath = path.ExpandPath(toolsetPath) + + var basePath string + if filepath.IsAbs(toolsetPath) { + basePath = "" + } else if wd := runConfig.WorkingDir; wd != "" { + basePath = wd + } else { + basePath = parentDir + } + + return path.ValidatePathInDirectory(toolsetPath, basePath) +} + func createTodoTool(_ context.Context, toolset latest.Toolset, _ string, _ *config.RuntimeConfig, _ string) (tools.ToolSet, error) { if toolset.Shared { return builtin.NewSharedTodoTool(), nil @@ -98,16 +115,7 @@ func createTasksTool(_ context.Context, toolset latest.Toolset, parentDir string toolsetPath = "tasks.json" } - var basePath string - if filepath.IsAbs(toolsetPath) { - basePath = "" - } else if wd := runConfig.WorkingDir; wd != "" { - basePath = wd - } else { - basePath = parentDir - } - - validatedPath, err := path.ValidatePathInDirectory(toolsetPath, basePath) + validatedPath, err := resolveToolsetPath(toolsetPath, parentDir, runConfig) if err != nil { return nil, fmt.Errorf("invalid tasks storage path: %w", err) } @@ -122,18 +130,8 @@ func createMemoryTool(_ context.Context, toolset latest.Toolset, parentDir strin var validatedMemoryPath string if toolset.Path != "" { - // Explicit path provided - resolve relative to working dir or parent dir - var basePath string - if filepath.IsAbs(toolset.Path) { - basePath = "" - } else if wd := runConfig.WorkingDir; wd != "" { - basePath = wd - } else { - basePath = parentDir - } - var err error - validatedMemoryPath, err = path.ValidatePathInDirectory(toolset.Path, basePath) + validatedMemoryPath, err = resolveToolsetPath(toolset.Path, parentDir, runConfig) if err != nil { return nil, fmt.Errorf("invalid memory database path: %w", err) }