refactor: home.Dir, home.Short, home.Long (#884)

Carlos Alexandro Becker created

* refactor: home.Dir, home.Short, home.Long

Centralized all home-related operations in a package and removed a bunch
of repeated code all over the place.

Signed-off-by: Carlos Alexandro Becker <caarlos0@users.noreply.github.com>

* test: more cases

Signed-off-by: Carlos Alexandro Becker <caarlos0@users.noreply.github.com>

* fix: more places

Signed-off-by: Carlos Alexandro Becker <caarlos0@users.noreply.github.com>

* test: fix on windows

Signed-off-by: Carlos Alexandro Becker <caarlos0@users.noreply.github.com>

* test: fix

Signed-off-by: Carlos Alexandro Becker <caarlos0@users.noreply.github.com>

* test: fix

Signed-off-by: Carlos Alexandro Becker <caarlos0@users.noreply.github.com>

---------

Signed-off-by: Carlos Alexandro Becker <caarlos0@users.noreply.github.com>

Change summary

internal/config/load.go                                  |  5 
internal/config/provider.go                              |  3 
internal/fsext/fileutil.go                               |  8 
internal/fsext/home.go                                   | 20 ---
internal/fsext/ls.go                                     |  3 
internal/fsext/parent.go                                 |  4 
internal/home/home.go                                    | 42 ++++++
internal/home/home_test.go                               | 26 ++++
internal/llm/prompt/prompt.go                            | 14 -
internal/llm/prompt/prompt_test.go                       | 62 ---------
internal/tui/components/chat/sidebar/sidebar.go          | 10 -
internal/tui/components/chat/splash/splash.go            |  9 -
internal/tui/components/dialogs/commands/loader.go       |  5 
internal/tui/components/dialogs/filepicker/filepicker.go |  6 
internal/tui/components/dialogs/models/apikey.go         |  5 
15 files changed, 98 insertions(+), 124 deletions(-)

Detailed changes

internal/config/load.go 🔗

@@ -16,6 +16,7 @@ import (
 	"github.com/charmbracelet/crush/internal/csync"
 	"github.com/charmbracelet/crush/internal/env"
 	"github.com/charmbracelet/crush/internal/fsext"
+	"github.com/charmbracelet/crush/internal/home"
 	"github.com/charmbracelet/crush/internal/log"
 )
 
@@ -584,7 +585,7 @@ func globalConfig() string {
 		return filepath.Join(localAppData, appName, fmt.Sprintf("%s.json", appName))
 	}
 
-	return filepath.Join(os.Getenv("HOME"), ".config", appName, fmt.Sprintf("%s.json", appName))
+	return filepath.Join(home.Dir(), ".config", appName, fmt.Sprintf("%s.json", appName))
 }
 
 // GlobalConfigData returns the path to the main data directory for the application.
@@ -606,5 +607,5 @@ func GlobalConfigData() string {
 		return filepath.Join(localAppData, appName, fmt.Sprintf("%s.json", appName))
 	}
 
-	return filepath.Join(os.Getenv("HOME"), ".local", "share", appName, fmt.Sprintf("%s.json", appName))
+	return filepath.Join(home.Dir(), ".local", "share", appName, fmt.Sprintf("%s.json", appName))
 }

internal/config/provider.go 🔗

@@ -12,6 +12,7 @@ import (
 	"time"
 
 	"github.com/charmbracelet/catwalk/pkg/catwalk"
+	"github.com/charmbracelet/crush/internal/home"
 )
 
 type ProviderClient interface {
@@ -41,7 +42,7 @@ func providerCacheFileData() string {
 		return filepath.Join(localAppData, appName, "providers.json")
 	}
 
-	return filepath.Join(os.Getenv("HOME"), ".local", "share", appName, "providers.json")
+	return filepath.Join(home.Dir(), ".local", "share", appName, "providers.json")
 }
 
 func saveProvidersInCache(path string, providers []catwalk.Provider) error {

internal/fsext/fileutil.go 🔗

@@ -10,6 +10,7 @@ import (
 
 	"github.com/bmatcuk/doublestar/v4"
 	"github.com/charlievieth/fastwalk"
+	"github.com/charmbracelet/crush/internal/home"
 
 	ignore "github.com/sabhiram/go-gitignore"
 )
@@ -182,12 +183,7 @@ func GlobWithDoubleStar(pattern, searchPath string, limit int) ([]string, bool,
 }
 
 func PrettyPath(path string) string {
-	// replace home directory with ~
-	homeDir, err := os.UserHomeDir()
-	if err == nil {
-		path = strings.ReplaceAll(path, homeDir, "~")
-	}
-	return path
+	return home.Short(path)
 }
 
 func DirTrim(pwd string, lim int) string {

internal/fsext/home.go 🔗

@@ -1,20 +0,0 @@
-package fsext
-
-import (
-	"cmp"
-	"os"
-	"os/user"
-	"sync"
-)
-
-var HomeDir = sync.OnceValue(func() string {
-	u, err := user.Current()
-	if err == nil {
-		return u.HomeDir
-	}
-	return cmp.Or(
-		os.Getenv("HOME"),
-		os.Getenv("USERPROFILE"),
-		os.Getenv("HOMEPATH"),
-	)
-})

internal/fsext/ls.go 🔗

@@ -9,6 +9,7 @@ import (
 
 	"github.com/charlievieth/fastwalk"
 	"github.com/charmbracelet/crush/internal/csync"
+	"github.com/charmbracelet/crush/internal/home"
 	ignore "github.com/sabhiram/go-gitignore"
 )
 
@@ -73,7 +74,7 @@ var commonIgnorePatterns = sync.OnceValue(func() ignore.IgnoreParser {
 })
 
 var homeIgnore = sync.OnceValue(func() ignore.IgnoreParser {
-	home := HomeDir()
+	home := home.Dir()
 	var lines []string
 	for _, name := range []string{
 		filepath.Join(home, ".gitignore"),

internal/fsext/parent.go 🔗

@@ -4,6 +4,8 @@ import (
 	"errors"
 	"os"
 	"path/filepath"
+
+	"github.com/charmbracelet/crush/internal/home"
 )
 
 // SearchParent searches for a target file or directory starting from dir
@@ -33,7 +35,7 @@ func SearchParent(dir, target string) (string, bool) {
 
 	for {
 		parent := filepath.Dir(previousParent)
-		if parent == previousParent || parent == HomeDir() {
+		if parent == previousParent || parent == home.Dir() {
 			return "", false
 		}
 

internal/home/home.go 🔗

@@ -0,0 +1,42 @@
+package home
+
+import (
+	"log/slog"
+	"os"
+	"path/filepath"
+	"strings"
+	"sync"
+)
+
+// Dir returns the users home directory, or if it fails, tries to create a new
+// temporary directory and use that instead.
+var Dir = sync.OnceValue(func() string {
+	home, err := os.UserHomeDir()
+	if err == nil {
+		slog.Debug("user home directory", "home", home)
+		return home
+	}
+	tmp, err := os.MkdirTemp("crush", "")
+	if err != nil {
+		slog.Error("could not find the user home directory")
+		return ""
+	}
+	slog.Warn("could not find the user home directory, using a temporary one", "home", tmp)
+	return tmp
+})
+
+// Short replaces the actual home path from [Dir] with `~`.
+func Short(p string) string {
+	if !strings.HasPrefix(p, Dir()) || Dir() == "" {
+		return p
+	}
+	return filepath.Join("~", strings.TrimPrefix(p, Dir()))
+}
+
+// Long replaces the `~` with actual home path from [Dir].
+func Long(p string) string {
+	if !strings.HasPrefix(p, "~") || Dir() == "" {
+		return p
+	}
+	return strings.Replace(p, "~", Dir(), 1)
+}

internal/home/home_test.go 🔗

@@ -0,0 +1,26 @@
+package home
+
+import (
+	"path/filepath"
+	"testing"
+
+	"github.com/stretchr/testify/require"
+)
+
+func TestDir(t *testing.T) {
+	require.NotEmpty(t, Dir())
+}
+
+func TestShort(t *testing.T) {
+	d := filepath.Join(Dir(), "documents", "file.txt")
+	require.Equal(t, filepath.FromSlash("~/documents/file.txt"), Short(d))
+	ad := filepath.FromSlash("/absolute/path/file.txt")
+	require.Equal(t, ad, Short(ad))
+}
+
+func TestLong(t *testing.T) {
+	d := filepath.FromSlash("~/documents/file.txt")
+	require.Equal(t, filepath.Join(Dir(), "documents", "file.txt"), Long(d))
+	ad := filepath.FromSlash("/absolute/path/file.txt")
+	require.Equal(t, ad, Long(ad))
+}

internal/llm/prompt/prompt.go 🔗

@@ -9,6 +9,7 @@ import (
 	"github.com/charmbracelet/crush/internal/config"
 	"github.com/charmbracelet/crush/internal/csync"
 	"github.com/charmbracelet/crush/internal/env"
+	"github.com/charmbracelet/crush/internal/home"
 )
 
 type PromptID string
@@ -44,18 +45,7 @@ func getContextFromPaths(workingDir string, contextPaths []string) string {
 
 // expandPath expands ~ and environment variables in file paths
 func expandPath(path string) string {
-	// Handle tilde expansion
-	if strings.HasPrefix(path, "~/") {
-		homeDir, err := os.UserHomeDir()
-		if err == nil {
-			path = filepath.Join(homeDir, path[2:])
-		}
-	} else if path == "~" {
-		homeDir, err := os.UserHomeDir()
-		if err == nil {
-			path = homeDir
-		}
-	}
+	path = home.Long(path)
 
 	// Handle environment variable expansion using the same pattern as config
 	if strings.HasPrefix(path, "$") {

internal/llm/prompt/prompt_test.go 🔗

@@ -2,10 +2,10 @@ package prompt
 
 import (
 	"os"
-	"path/filepath"
-	"runtime"
 	"strings"
 	"testing"
+
+	"github.com/charmbracelet/crush/internal/home"
 )
 
 func TestExpandPath(t *testing.T) {
@@ -25,16 +25,14 @@ func TestExpandPath(t *testing.T) {
 			name:  "tilde expansion",
 			input: "~/documents",
 			expected: func() string {
-				home, _ := os.UserHomeDir()
-				return filepath.Join(home, "documents")
+				return home.Dir() + "/documents"
 			},
 		},
 		{
 			name:  "tilde only",
 			input: "~",
 			expected: func() string {
-				home, _ := os.UserHomeDir()
-				return home
+				return home.Dir()
 			},
 		},
 		{
@@ -69,55 +67,3 @@ func TestExpandPath(t *testing.T) {
 		})
 	}
 }
-
-func TestProcessContextPaths(t *testing.T) {
-	// Create a temporary directory and file for testing
-	tmpDir := t.TempDir()
-	testFile := filepath.Join(tmpDir, "test.txt")
-	testContent := "test content"
-
-	err := os.WriteFile(testFile, []byte(testContent), 0o644)
-	if err != nil {
-		t.Fatalf("Failed to create test file: %v", err)
-	}
-
-	// Test with absolute path to file
-	result := processContextPaths("", []string{testFile})
-	expected := "# From:" + testFile + "\n" + testContent
-
-	if result != expected {
-		t.Errorf("processContextPaths with absolute path failed.\nGot: %q\nWant: %q", result, expected)
-	}
-
-	// Test with directory path (should process all files in directory)
-	result = processContextPaths("", []string{tmpDir})
-	if !strings.Contains(result, testContent) {
-		t.Errorf("processContextPaths with directory path failed to include file content")
-	}
-
-	// Test with tilde expansion (if we can create a file in home directory)
-	tmpDir = t.TempDir()
-	setHomeEnv(t, tmpDir)
-	homeTestFile := filepath.Join(tmpDir, "crush_test_file.txt")
-	err = os.WriteFile(homeTestFile, []byte(testContent), 0o644)
-	if err == nil {
-		defer os.Remove(homeTestFile) // Clean up
-
-		tildeFile := "~/crush_test_file.txt"
-		result = processContextPaths("", []string{tildeFile})
-		expected = "# From:" + homeTestFile + "\n" + testContent
-
-		if result != expected {
-			t.Errorf("processContextPaths with tilde expansion failed.\nGot: %q\nWant: %q", result, expected)
-		}
-	}
-}
-
-func setHomeEnv(tb testing.TB, path string) {
-	tb.Helper()
-	key := "HOME"
-	if runtime.GOOS == "windows" {
-		key = "USERPROFILE"
-	}
-	tb.Setenv(key, path)
-}

internal/tui/components/chat/sidebar/sidebar.go 🔗

@@ -3,7 +3,6 @@ package sidebar
 import (
 	"context"
 	"fmt"
-	"os"
 	"slices"
 	"strings"
 
@@ -14,6 +13,7 @@ import (
 	"github.com/charmbracelet/crush/internal/diff"
 	"github.com/charmbracelet/crush/internal/fsext"
 	"github.com/charmbracelet/crush/internal/history"
+	"github.com/charmbracelet/crush/internal/home"
 	"github.com/charmbracelet/crush/internal/lsp"
 	"github.com/charmbracelet/crush/internal/pubsub"
 	"github.com/charmbracelet/crush/internal/session"
@@ -609,11 +609,5 @@ func (m *sidebarCmp) SetCompactMode(compact bool) {
 func cwd() string {
 	cwd := config.Get().WorkingDir()
 	t := styles.CurrentTheme()
-	// Replace home directory with ~, unless we're at the top level of the
-	// home directory).
-	homeDir, err := os.UserHomeDir()
-	if err == nil && cwd != homeDir {
-		cwd = strings.ReplaceAll(cwd, homeDir, "~")
-	}
-	return t.S().Muted.Render(cwd)
+	return t.S().Muted.Render(home.Short(cwd))
 }

internal/tui/components/chat/splash/splash.go 🔗

@@ -2,7 +2,6 @@ package splash
 
 import (
 	"fmt"
-	"os"
 	"strings"
 	"time"
 
@@ -11,6 +10,7 @@ import (
 	tea "github.com/charmbracelet/bubbletea/v2"
 	"github.com/charmbracelet/catwalk/pkg/catwalk"
 	"github.com/charmbracelet/crush/internal/config"
+	"github.com/charmbracelet/crush/internal/home"
 	"github.com/charmbracelet/crush/internal/llm/prompt"
 	"github.com/charmbracelet/crush/internal/tui/components/chat"
 	"github.com/charmbracelet/crush/internal/tui/components/core"
@@ -648,12 +648,7 @@ func (s *splashCmp) cwdPart() string {
 }
 
 func (s *splashCmp) cwd() string {
-	cwd := config.Get().WorkingDir()
-	homeDir, err := os.UserHomeDir()
-	if err == nil && cwd != homeDir {
-		cwd = strings.ReplaceAll(cwd, homeDir, "~")
-	}
-	return cwd
+	return home.Short(config.Get().WorkingDir())
 }
 
 func LSPList(maxWidth int) []string {

internal/tui/components/dialogs/commands/loader.go 🔗

@@ -10,6 +10,7 @@ import (
 
 	tea "github.com/charmbracelet/bubbletea/v2"
 	"github.com/charmbracelet/crush/internal/config"
+	"github.com/charmbracelet/crush/internal/home"
 	"github.com/charmbracelet/crush/internal/tui/util"
 )
 
@@ -54,7 +55,7 @@ func buildCommandSources(cfg *config.Config) []commandSource {
 	}
 
 	// Home directory
-	if home, err := os.UserHomeDir(); err == nil {
+	if home := home.Dir(); home != "" {
 		sources = append(sources, commandSource{
 			path:   filepath.Join(home, ".crush", "commands"),
 			prefix: UserCommandPrefix,
@@ -73,7 +74,7 @@ func buildCommandSources(cfg *config.Config) []commandSource {
 func getXDGCommandsDir() string {
 	xdgHome := os.Getenv("XDG_CONFIG_HOME")
 	if xdgHome == "" {
-		if home, err := os.UserHomeDir(); err == nil {
+		if home := home.Dir(); home != "" {
 			xdgHome = filepath.Join(home, ".config")
 		}
 	}

internal/tui/components/dialogs/filepicker/filepicker.go 🔗

@@ -11,6 +11,7 @@ import (
 	"github.com/charmbracelet/bubbles/v2/help"
 	"github.com/charmbracelet/bubbles/v2/key"
 	tea "github.com/charmbracelet/bubbletea/v2"
+	"github.com/charmbracelet/crush/internal/home"
 	"github.com/charmbracelet/crush/internal/message"
 	"github.com/charmbracelet/crush/internal/tui/components/core"
 	"github.com/charmbracelet/crush/internal/tui/components/dialogs"
@@ -60,7 +61,7 @@ func NewFilePickerCmp(workingDir string) FilePicker {
 		if cwd, err := os.Getwd(); err == nil {
 			fp.CurrentDirectory = cwd
 		} else {
-			fp.CurrentDirectory, _ = os.UserHomeDir()
+			fp.CurrentDirectory = home.Dir()
 		}
 	}
 
@@ -106,8 +107,7 @@ func (m *model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
 		}
 		if key.Matches(msg, m.filePicker.KeyMap.Back) {
 			// make sure we don't go back if we are at the home directory
-			homeDir, _ := os.UserHomeDir()
-			if m.filePicker.CurrentDirectory == homeDir {
+			if m.filePicker.CurrentDirectory == home.Dir() {
 				return m, nil
 			}
 		}

internal/tui/components/dialogs/models/apikey.go 🔗

@@ -2,13 +2,12 @@ package models
 
 import (
 	"fmt"
-	"strings"
 
 	"github.com/charmbracelet/bubbles/v2/spinner"
 	"github.com/charmbracelet/bubbles/v2/textinput"
 	tea "github.com/charmbracelet/bubbletea/v2"
 	"github.com/charmbracelet/crush/internal/config"
-	"github.com/charmbracelet/crush/internal/fsext"
+	"github.com/charmbracelet/crush/internal/home"
 	"github.com/charmbracelet/crush/internal/tui/styles"
 	"github.com/charmbracelet/lipgloss/v2"
 )
@@ -145,7 +144,7 @@ func (a *APIKeyInput) View() string {
 	inputView := a.input.View()
 
 	dataPath := config.GlobalConfigData()
-	dataPath = strings.Replace(dataPath, fsext.HomeDir(), "~", 1)
+	dataPath = home.Short(dataPath)
 	helpText := styles.CurrentTheme().S().Muted.
 		Render(fmt.Sprintf("This will be written to the global configuration: %s", dataPath))